Skip to main content

broadcast_hls/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(not(feature = "std"), no_std)]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4#![warn(missing_docs)]
5// Runnable examples, embedded so they render on docs.rs and stay in sync with
6// the actual `examples/*.rs` files (shown, not compiled).
7#![doc = "\n## Runnable examples\n"]
8#![doc = "Run with `cargo run -p broadcast-hls --example <name>`.\n"]
9#![doc = "\n### `build_playlist`\n\n```rust,ignore"]
10#![doc = include_str!("../examples/build_playlist.rs")]
11#![doc = "```\n\n### `parse_playlist`\n\n```rust,ignore"]
12#![doc = include_str!("../examples/parse_playlist.rs")]
13#![doc = "```"]
14
15extern crate alloc;
16
17mod error;
18
19pub use error::{Error, Result};
20
21use alloc::collections::BTreeMap;
22use alloc::collections::BTreeSet;
23use alloc::format;
24use alloc::string::{String, ToString};
25use alloc::vec::Vec;
26
27use broadcast_common::hex::hex_encode;
28
29/// A CENC protection scheme (`schm.scheme_type`) — ISO/IEC 23001-7 §4.
30///
31/// Re-exported from [`broadcast_common::cenc`], which holds the single
32/// definition. CENC is *Common* Encryption — a container-independent scheme
33/// identity — so it sits below both this crate and `transmux` (which owns the
34/// ISOBMFF `schm`/`tenc`/`senc` boxes carrying it), rather than being defined
35/// twice and converted at the boundary (issues #564, #878).
36pub use broadcast_common::CencScheme;
37
38// ---------------------------------------------------------------------------
39// CENC/CBCS DRM signalling — ISO/IEC 23001-7 `cbcs` over CMAF-HLS (issue #564).
40// ---------------------------------------------------------------------------
41
42/// `KEYFORMAT` for the generic CENC identification (mirrors DASH's
43/// `ContentProtection@schemeIdUri` for the "common" scheme —
44/// ISO/IEC 23001-7 / `urn:mpeg:dash:mp4protection:2011`).
45pub const CENC_KEYFORMAT: &str = "urn:mpeg:dash:mp4protection:2011";
46
47/// `KEYFORMATVERSIONS` for [`CENC_KEYFORMAT`] (there is only version `"1"`).
48pub const CENC_KEYFORMATVERSIONS: &str = "1";
49
50/// Build the `#EXT-X-KEY` tag line for a CMAF track protected with either
51/// Common Encryption scheme (RFC 8216bis §4.4.4.4): `cbcs` (AES-128 pattern
52/// CBC) renders `METHOD=SAMPLE-AES`, `cenc` (AES-128 full-block CTR) renders
53/// `METHOD=SAMPLE-AES-CTR`. In both cases `KEYFORMAT`/`KEYFORMATVERSIONS`
54/// per [`CENC_KEYFORMAT`]/[`CENC_KEYFORMATVERSIONS`], plus the `KEYID`
55/// attribute Apple's HLS CMAF/fMP4 authoring guidance uses to identify the
56/// CENC key ID. Per §4.4.4.4, encryption with SAMPLE-AES-CTR is defined only
57/// for fMP4 Media Segments and the `IV` attribute MUST NOT be present, so no
58/// `IV` is ever emitted here.
59///
60/// `key_uri` is caller-supplied (a key-server URL, `skd://`, or `data:`
61/// URI — no DRM logic lives here) and `kid` is the track's Track Encryption
62/// Box default KID (`tenc.default_kid`, ISO/IEC 14496-12 §8.12.1 — the
63/// `transmux` crate's `cenc::TrackEncryptionBox::default_kid` /
64/// `media::TrackEncryption::tenc::default_kid`).
65///
66/// `Err` if `key_uri` cannot be represented as a quoted-string (`"`, CR or
67/// LF — RFC 8216 §4.2; issue #1140 / audit r05-W10: a key-server URL
68/// assembled from request data previously went straight into the tag
69/// unvalidated, an attribute-list-injection vector).
70pub fn cenc_ext_x_key(scheme: CencScheme, kid: &[u8; 16], key_uri: &str) -> Result<Option<String>> {
71    // Audit BH-W1 (issue #1111): `cenc` used to return `None` on the claim
72    // that "CTR is not a valid HLS METHOD". RFC 8216bis §4.4.4.4 defines
73    // exactly that method — SAMPLE-AES-CTR — for `cenc`-protected fMP4.
74    let method = match scheme {
75        CencScheme::Cbcs => "SAMPLE-AES",
76        CencScheme::Cenc => "SAMPLE-AES-CTR",
77        // Other CencScheme values (the enum is #[non_exhaustive]) have no
78        // defined HLS METHOD.
79        _ => return Ok(None),
80    };
81    let mut s = format!("#EXT-X-KEY:METHOD={method}");
82    let attrs = [
83        (String::from("URI"), AttrValue::quoted(key_uri)?),
84        (
85            String::from("KEYFORMAT"),
86            AttrValue::quoted(CENC_KEYFORMAT)?,
87        ),
88        (
89            String::from("KEYFORMATVERSIONS"),
90            AttrValue::quoted(CENC_KEYFORMATVERSIONS)?,
91        ),
92        (
93            String::from("KEYID"),
94            AttrValue::bare(format!("0x{}", hex_encode(kid)))?,
95        ),
96    ];
97    render_attribute_list(&mut s, &attrs);
98    Ok(Some(s))
99}
100
101/// A byte sub-range into a resource.
102///
103/// Shared by three tags that all use the same underlying notation:
104/// - `#EXT-X-BYTERANGE:<n>[@<o>]` (RFC 8216bis §4.4.4.2) — [`MediaSegment::byte_range`].
105/// - `#EXT-X-PART`'s `BYTERANGE="<n>[@<o>]"` attribute (RFC 8216bis
106///   §4.4.4.9, "same format as the EXT-X-BYTERANGE tag") — [`PartSpec::byte_range`].
107/// - `#EXT-X-MAP`'s `BYTERANGE="<n>@<o>"` attribute (RFC 8216bis §4.4.4.5) —
108///   [`MapTag::byte_range`]. Unlike the other two, the spec says the offset
109///   `o` is **REQUIRED** here (there is no "previous sub-range" to continue
110///   from for an Initialization Section).
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct ByteRange {
113    /// `n` — length of the sub-range in bytes.
114    pub length: u64,
115    /// `o` — byte offset of the sub-range from the start of the resource.
116    /// `None` means "immediately following the previous Media/Partial
117    /// Segment's sub-range of the same resource" (only meaningful for
118    /// `EXT-X-BYTERANGE`/`EXT-X-PART`'s `BYTERANGE`; `EXT-X-MAP`'s
119    /// `BYTERANGE` always carries `Some`).
120    pub offset: Option<u64>,
121}
122
123impl ByteRange {
124    /// Render the `<n>[@<o>]` wire notation (used inside a quoted attribute
125    /// value for `PART`/`MAP`, or as the whole `#EXT-X-BYTERANGE` tag value).
126    fn render(&self) -> String {
127        match self.offset {
128            Some(o) => format!("{}@{o}", self.length),
129            None => format!("{}", self.length),
130        }
131    }
132
133    /// Parse the `<n>[@<o>]` wire notation.
134    fn parse(s: &str, line_no: usize, line: &str) -> Result<Self> {
135        let mut split = s.splitn(2, '@');
136        let n = split.next().unwrap_or("");
137        let length = parse_decimal_int::<u64>(n, line_no, line, "BYTERANGE length")?;
138        let offset = match split.next() {
139            Some(o) => Some(parse_decimal_int::<u64>(
140                o,
141                line_no,
142                line,
143                "BYTERANGE offset",
144            )?),
145            None => None,
146        };
147        if let Some(o) = offset.filter(|&o| o.checked_add(length).is_none()) {
148            return Err(Error::HlsParse {
149                line_no,
150                line: line.to_string(),
151                reason: format!("BYTERANGE offset ({o}) + length ({length}) overflows u64"),
152            });
153        }
154        Ok(ByteRange { length, offset })
155    }
156}
157
158/// The Media Initialization Section reference of `#EXT-X-MAP` (RFC 8216bis
159/// §4.4.4.5) — see [`MediaSegment::map`] for carry-forward/dedup semantics.
160#[derive(Debug, Clone, PartialEq, Eq, Default)]
161pub struct MapTag {
162    /// `URI` — the resource containing the Media Initialization Section
163    /// (REQUIRED).
164    pub uri: String,
165    /// `BYTERANGE` — a sub-range of `uri` containing just the
166    /// Initialization Section. `None` means the entire resource. The
167    /// offset is always present when this is `Some` (spec requires it here,
168    /// unlike [`MediaSegment::byte_range`]/[`PartSpec::byte_range`]).
169    pub byte_range: Option<ByteRange>,
170    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
171    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
172    pub extra_attrs: Vec<(String, AttrValue)>,
173}
174
175/// `TYPE` attribute of `#EXT-X-PRELOAD-HINT` (RFC 8216bis §4.4.5.3).
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177#[non_exhaustive]
178pub enum PreloadHintType {
179    /// `PART` — the hinted resource is a Partial Segment.
180    #[default]
181    Part,
182    /// `MAP` — the hinted resource is a Media Initialization Section.
183    Map,
184}
185
186impl PreloadHintType {
187    /// The spec token (`"PART"` / `"MAP"`).
188    pub fn name(&self) -> &'static str {
189        match self {
190            PreloadHintType::Part => "PART",
191            PreloadHintType::Map => "MAP",
192        }
193    }
194}
195
196broadcast_common::impl_spec_display!(PreloadHintType);
197
198/// `#EXT-X-RENDITION-REPORT` (RFC 8216bis §4.4.5.4) — a pointer to the
199/// current state of an associated Rendition's own Media Playlist, so an
200/// LL-HLS client following one Rendition can discover how far another has
201/// progressed without polling it.
202#[derive(Debug, Clone, PartialEq, Eq, Default)]
203pub struct RenditionReport {
204    /// `URI` of the Rendition's Media Playlist, relative to the playlist
205    /// carrying this tag (REQUIRED).
206    pub uri: String,
207    /// `LAST-MSN` — Media Sequence Number of the last segment (or partial
208    /// segment, if any) currently in that Rendition (REQUIRED).
209    pub last_msn: u64,
210    /// `LAST-PART` — Part Index of the last partial segment at `last_msn`,
211    /// if that Rendition has partial segments.
212    pub last_part: Option<u64>,
213    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
214    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
215    pub extra_attrs: Vec<(String, AttrValue)>,
216}
217
218/// `#EXT-X-SKIP` (RFC 8216bis §4.4.5.2) — present on a Playlist Delta Update
219/// response in place of the segments/tags before the Skip Boundary.
220#[derive(Debug, Clone, PartialEq, Eq, Default)]
221pub struct SkipInfo {
222    /// `SKIPPED-SEGMENTS` — count of Media Segments elided (REQUIRED).
223    pub skipped_segments: u64,
224    /// `RECENTLY-REMOVED-DATERANGES` — `EXT-X-DATERANGE` `ID`s removed from
225    /// the playlist recently (tab-delimited on the wire). Empty when the
226    /// attribute is absent.
227    pub recently_removed_daterange_ids: Vec<String>,
228    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
229    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
230    pub extra_attrs: Vec<(String, AttrValue)>,
231}
232
233/// A single partial segment ("part") of a [`MediaSegment`] — RFC 8216bis
234/// §4.4.4.9 (`#EXT-X-PART`).
235///
236/// A part is an independently addressable CMAF chunk (a `moof`+`mdat` fragment)
237/// covering a sub-duration of its parent segment; a client can fetch and play it
238/// before the parent segment is complete. Parts are emitted as `#EXT-X-PART`
239/// lines immediately before the parent segment's `#EXTINF`.
240#[derive(Debug, Clone, PartialEq, Default)]
241pub struct PartSpec {
242    /// The part URI (e.g. `"seg0.1.m4s"`).
243    pub uri: String,
244    /// The part duration in seconds (e.g. `0.334`).
245    pub duration: DecimalSeconds,
246    /// If `true`, render `,INDEPENDENT=YES` — the part begins with an
247    /// independently decodable frame (a sync sample). RFC 8216bis §4.4.4.9.
248    pub independent: bool,
249    /// `BYTERANGE` attribute (RFC 8216bis §4.4.4.9) — the part is a
250    /// sub-range of the resource named by [`Self::uri`], same `<n>[@<o>]`
251    /// format as [`MediaSegment::byte_range`]. `None` when the part is the
252    /// entire resource.
253    pub byte_range: Option<ByteRange>,
254    /// `GAP` attribute (RFC 8216bis §4.4.4.9) — `true` if this partial
255    /// segment is not actually available (a hole in the part list).
256    pub gap: bool,
257    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
258    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
259    pub extra_attrs: Vec<(String, AttrValue)>,
260}
261
262/// An in-progress (open) LL-HLS segment: its parts are known and being served,
263/// but the segment is not yet complete, so it carries no `#EXTINF`/URI
264/// (RFC 8216bis §4.4.4.9 — an open segment is represented by its trailing
265/// `#EXT-X-PART` lines only, until it closes).
266#[derive(Debug, Clone, PartialEq)]
267#[non_exhaustive]
268pub struct OpenSegment {
269    /// The parts of the in-progress segment, in order.
270    pub parts: Vec<PartSpec>,
271    /// The Media Initialization Section in effect for this segment (RFC
272    /// 8216bis §4.4.4.5) — `#EXT-X-MAP` applies "until the next `EXT-X-MAP`
273    /// tag or the end of the Playlist", so this carries forward from the
274    /// last `#EXT-X-MAP` seen, whether or not any segment has *closed* yet.
275    /// `None` if no `#EXT-X-MAP` has appeared at all (rare in practice — a
276    /// live LL-HLS playlist's very first segment normally needs one).
277    pub map: Option<MapTag>,
278    /// Verbatim segment-scoped tag lines (the [`MediaSegment::pre_tags`]
279    /// counterpart for the open segment): segment-defining tags that appear
280    /// after the last closed segment but before the pending one — e.g. a
281    /// `#EXT-X-KEY` for the next key period, or a `#EXT-X-PROGRAM-DATE-TIME`
282    /// for the in-progress segment (audit BH-W5, issue #1111). Rendered
283    /// after the open segment's map/parts, in order.
284    pub pre_tags: Vec<String>,
285    /// `#EXT-X-PROGRAM-DATE-TIME` (RFC 8216bis §4.4.4.6) applying to this
286    /// open (in-progress) segment, if any.
287    pub program_date_time: Option<ProgramDateTime>,
288}
289
290impl OpenSegment {
291    /// Build an open segment from its in-progress parts, with no map (see
292    /// [`Self::with_map`] to attach one).
293    pub fn new(parts: Vec<PartSpec>) -> Self {
294        Self {
295            parts,
296            map: None,
297            pre_tags: Vec::new(),
298            program_date_time: None,
299        }
300    }
301
302    /// Attach the Media Initialization Section in effect for this segment.
303    pub fn with_map(mut self, map: MapTag) -> Self {
304        self.map = Some(map);
305        self
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Media or Multivariant Playlist Tags — RFC 8216bis §4.4.2. Valid in either
311// a `MediaPlaylist` or a `MasterPlaylist` (issue #872).
312// ---------------------------------------------------------------------------
313
314/// `#EXT-X-START` (RFC 8216bis §4.4.2.2) — a preferred playback start point.
315/// Valid in either a [`MediaPlaylist`] or a [`MasterPlaylist`].
316#[derive(Debug, Clone, PartialEq)]
317#[non_exhaustive]
318pub struct StartPoint {
319    /// `TIME-OFFSET` — signed seconds from the start of the Playlist
320    /// (positive) or from the end of the last Media Segment (negative).
321    /// REQUIRED.
322    pub time_offset: SignedDecimalSeconds,
323    /// `PRECISE` — if `true`, a client should not render samples before
324    /// `time_offset` within the segment it lands in. Absence on the wire
325    /// means `false` (RFC 8216bis §4.4.2.2).
326    pub precise: bool,
327    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
328    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
329    pub extra_attrs: Vec<(String, AttrValue)>,
330}
331
332/// A single `#EXT-X-DEFINE` variable declaration (RFC 8216bis §4.4.2.3).
333/// Unlike every other §4.4.2 tag, `EXT-X-DEFINE` MAY appear more than once
334/// per Playlist, so it is carried as a `Vec` on both [`MediaPlaylist`] and
335/// [`MasterPlaylist`] rather than a single field.
336#[derive(Debug, Clone, PartialEq, Eq)]
337#[non_exhaustive]
338pub enum Define {
339    /// `NAME`/`VALUE` form — declares a Variable with a literal value.
340    Name {
341        /// The Variable Name (`[a-zA-Z0-9_-]` per spec).
342        name: String,
343        /// The Variable Value (MAY be empty).
344        value: String,
345        /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
346        /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
347        extra_attrs: Vec<(String, AttrValue)>,
348    },
349    /// `IMPORT` form — imports a Variable of the same name from the parent
350    /// Multivariant Playlist. The spec says this **MUST NOT** occur in a
351    /// [`MasterPlaylist`] (Multivariant Playlist) — only in a
352    /// [`MediaPlaylist`] loaded from one — but that is a cross-file
353    /// constraint this single-playlist parser cannot check, so it is not
354    /// enforced here (see the module docs on this crate's general
355    /// MUST-constraint leniency).
356    Import {
357        /// The imported Variable Name.
358        name: String,
359        /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
360        /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
361        extra_attrs: Vec<(String, AttrValue)>,
362    },
363    /// `QUERYPARAM` form — imports a Variable from the query parameter of
364    /// the same name in the Playlist's own URI.
365    QueryParam {
366        /// The Variable Name / query parameter name.
367        name: String,
368        /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
369        /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
370        extra_attrs: Vec<(String, AttrValue)>,
371    },
372}
373
374// ---------------------------------------------------------------------------
375// Media Playlist Tags — RFC 8216bis §4.4.3.5.
376// ---------------------------------------------------------------------------
377
378/// `#EXT-X-PLAYLIST-TYPE` (RFC 8216bis §4.4.3.5) mutability declaration —
379/// [`MediaPlaylist`]-only.
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381#[non_exhaustive]
382pub enum PlaylistType {
383    /// `EVENT` — segments can only be appended, never removed.
384    Event,
385    /// `VOD` (Video On Demand) — the Playlist can never change.
386    Vod,
387}
388
389impl PlaylistType {
390    /// The spec token (`"EVENT"` / `"VOD"`).
391    pub fn name(&self) -> &'static str {
392        match self {
393            PlaylistType::Event => "EVENT",
394            PlaylistType::Vod => "VOD",
395        }
396    }
397}
398
399broadcast_common::impl_spec_display!(PlaylistType);
400
401// ---------------------------------------------------------------------------
402// Multivariant Playlist Tags — RFC 8216bis §4.4.6.4 / §4.4.6.5 / §4.4.6.6.
403// All three are [`MasterPlaylist`]-only.
404// ---------------------------------------------------------------------------
405
406/// `FORMAT` attribute of `#EXT-X-SESSION-DATA` (RFC 8216bis §4.4.6.4).
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
408#[non_exhaustive]
409pub enum SessionDataFormat {
410    /// `JSON` — the default when the attribute (or the whole `URI`
411    /// attribute it qualifies) is absent.
412    #[default]
413    Json,
414    /// `RAW` — the URI names a binary file.
415    Raw,
416}
417
418impl SessionDataFormat {
419    /// The spec token (`"JSON"` / `"RAW"`).
420    pub fn name(&self) -> &'static str {
421        match self {
422            SessionDataFormat::Json => "JSON",
423            SessionDataFormat::Raw => "RAW",
424        }
425    }
426}
427
428broadcast_common::impl_spec_display!(SessionDataFormat);
429
430/// The mutually-exclusive `VALUE`/`URI` content of `#EXT-X-SESSION-DATA`
431/// (RFC 8216bis §4.4.6.4: "Each ... tag MUST contain either a VALUE or URI
432/// attribute, but not both").
433#[derive(Debug, Clone, PartialEq, Eq)]
434#[non_exhaustive]
435pub enum SessionDataContent {
436    /// `VALUE` — a literal data string.
437    Value(String),
438    /// `URI` (+ `FORMAT`) — a reference to an external resource.
439    Uri {
440        /// The `URI` attribute.
441        uri: String,
442        /// The `FORMAT` attribute — only meaningful here (ignored by the
443        /// spec when `URI` is absent, i.e. for [`SessionDataContent::Value`]).
444        format: SessionDataFormat,
445    },
446}
447
448/// `#EXT-X-SESSION-DATA` (RFC 8216bis §4.4.6.4) — arbitrary session data
449/// carried in a [`MasterPlaylist`] (Multivariant Playlist only). A Playlist
450/// MAY carry multiple entries, including repeats of the same `DATA-ID`
451/// distinguished by `LANGUAGE`.
452#[derive(Debug, Clone, PartialEq, Eq)]
453#[non_exhaustive]
454pub struct SessionData {
455    /// `DATA-ID` — identifies this data value (REQUIRED).
456    pub data_id: String,
457    /// The mutually-exclusive `VALUE`/`URI` payload.
458    pub content: SessionDataContent,
459    /// `LANGUAGE` — an RFC 5646 language tag, typically qualifying a
460    /// [`SessionDataContent::Value`].
461    pub language: Option<String>,
462    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
463    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
464    pub extra_attrs: Vec<(String, AttrValue)>,
465}
466
467/// `METHOD` attribute shared by `#EXT-X-KEY`/`#EXT-X-SESSION-KEY`
468/// (RFC 8216bis §4.4.4.4 / §4.4.6.5).
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470#[non_exhaustive]
471pub enum EncryptionMethod {
472    /// `NONE` — not encrypted. The spec disallows this value on
473    /// `EXT-X-SESSION-KEY` specifically (not enforced at parse time here).
474    None,
475    /// `AES-128` — whole-segment AES-128-CBC.
476    Aes128,
477    /// `SAMPLE-AES` — per-sample AES (`cbcs` scheme for fMP4).
478    SampleAes,
479    /// `SAMPLE-AES-CTR` — per-sample AES-CTR (`cenc` scheme for fMP4).
480    SampleAesCtr,
481    /// `AES-256-GCM` — whole-segment AES-256-GCM.
482    Aes256Gcm,
483}
484
485impl EncryptionMethod {
486    /// The spec token.
487    pub fn name(&self) -> &'static str {
488        match self {
489            EncryptionMethod::None => "NONE",
490            EncryptionMethod::Aes128 => "AES-128",
491            EncryptionMethod::SampleAes => "SAMPLE-AES",
492            EncryptionMethod::SampleAesCtr => "SAMPLE-AES-CTR",
493            EncryptionMethod::Aes256Gcm => "AES-256-GCM",
494        }
495    }
496}
497
498broadcast_common::impl_spec_display!(EncryptionMethod);
499
500/// `#EXT-X-SESSION-KEY` (RFC 8216bis §4.4.6.5) — preloadable decryption key
501/// info for a [`MasterPlaylist`] (Multivariant Playlist only), carrying the
502/// same attributes as `#EXT-X-KEY` (§4.4.4.4) except that the spec requires
503/// `METHOD` not be `NONE` (enforced at parse time).
504#[derive(Debug, Clone, PartialEq, Eq)]
505#[non_exhaustive]
506pub struct SessionKey {
507    /// `METHOD` (REQUIRED).
508    pub method: EncryptionMethod,
509    /// `URI` — REQUIRED unless `method` is [`EncryptionMethod::None`].
510    pub uri: Option<String>,
511    /// `IV` — 128-bit Initialization Vector.
512    pub iv: Option<[u8; 16]>,
513    /// `KEYFORMAT` — absence on the wire implies `"identity"`.
514    pub keyformat: Option<String>,
515    /// `KEYFORMATVERSIONS` — absence on the wire implies `"1"`.
516    pub keyformatversions: Option<String>,
517    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
518    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
519    pub extra_attrs: Vec<(String, AttrValue)>,
520}
521
522/// `#EXT-X-CONTENT-STEERING` (RFC 8216bis §4.4.6.6) — a pointer to a Content
523/// Steering Manifest. [`MasterPlaylist`]-only; at most one per Playlist.
524#[derive(Debug, Clone, PartialEq, Eq)]
525#[non_exhaustive]
526pub struct ContentSteering {
527    /// `SERVER-URI` — the Steering Manifest URI (REQUIRED).
528    pub server_uri: String,
529    /// `PATHWAY-ID` — the Pathway to apply before the first Steering
530    /// Manifest has been obtained.
531    pub pathway_id: Option<String>,
532    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
533    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
534    pub extra_attrs: Vec<(String, AttrValue)>,
535}
536
537/// `#EXT-X-PROGRAM-DATE-TIME` value (RFC 8216bis §4.4.4.6) — an absolute
538/// date/time associated with the first sample of a Media Segment. Stored as a
539/// millisecond-precision UTC [`jiff::Timestamp`] (the wire form is canonicalised
540/// to `YYYY-MM-DDThh:mm:ss.SSSZ` on render; sub-millisecond precision and any
541/// offset are normalised away on parse, so a round trip compares equal as a
542/// typed value even when the textual form changes).
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544#[non_exhaustive]
545pub struct ProgramDateTime(jiff::Timestamp);
546
547impl ProgramDateTime {
548    /// Parse an ISO 8601 / RFC 3339 date-time (`2010-02-19T14:54:23.031+08:00`,
549    /// `2024-01-01T00:00:00.000Z`, or a zone-less value, which the spec says
550    /// the client SHOULD treat as UTC). Sub-millisecond precision is truncated to
551    /// milliseconds; offsets are converted to UTC. Returns an error if the value
552    /// is not a recognisable date/time.
553    pub fn parse(s: &str) -> crate::Result<Self> {
554        let trimmed = s.trim();
555        let ts = if let Ok(ts) = trimmed.parse::<jiff::Timestamp>() {
556            ts
557        } else if let Ok(dt) = trimmed.parse::<jiff::civil::DateTime>() {
558            dt.to_zoned(jiff::tz::TimeZone::UTC)
559                .map_err(|_| crate::Error::HlsParse {
560                    line_no: 0,
561                    line: trimmed.to_string(),
562                    reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid date/time".to_string(),
563                })?
564                .timestamp()
565        } else {
566            return Err(crate::Error::HlsParse {
567                line_no: 0,
568                line: trimmed.to_string(),
569                reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid ISO 8601 date/time"
570                    .to_string(),
571            });
572        };
573        let ms = ts.as_millisecond();
574        let ts = jiff::Timestamp::from_millisecond(ms).map_err(|_| crate::Error::HlsParse {
575            line_no: 0,
576            line: trimmed.to_string(),
577            reason: "EXT-X-PROGRAM-DATE-TIME value is out of range".to_string(),
578        })?;
579        Ok(Self(ts))
580    }
581
582    /// Wrap a timestamp, truncating it to millisecond precision (the wire
583    /// precision, §4.4.4.6).
584    pub fn from_timestamp(ts: jiff::Timestamp) -> Self {
585        // `as_millisecond` of a valid timestamp is always representable.
586        Self(jiff::Timestamp::from_millisecond(ts.as_millisecond()).unwrap_or(ts))
587    }
588
589    /// The underlying millisecond-precision UTC timestamp.
590    pub fn timestamp(&self) -> jiff::Timestamp {
591        self.0
592    }
593
594    /// Render in canonical form `YYYY-MM-DDThh:mm:ss.SSSZ`.
595    pub fn render(&self) -> alloc::string::String {
596        self.0.strftime("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
597    }
598}
599
600impl core::str::FromStr for ProgramDateTime {
601    type Err = crate::Error;
602    fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
603        Self::parse(s)
604    }
605}
606
607impl core::fmt::Display for ProgramDateTime {
608    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
609        f.write_str(&self.render())
610    }
611}
612
613/// A single media segment in a media playlist.
614#[derive(Debug, Clone, PartialEq, Default)]
615pub struct MediaSegment {
616    /// The segment URI (e.g. `"seg0.m4s"`).
617    pub uri: String,
618    /// The segment duration in seconds (e.g. `9.009`).
619    pub duration: DecimalSeconds,
620    /// If `true`, emit `#EXT-X-DISCONTINUITY` immediately before this
621    /// segment's `#EXTINF` line — RFC 8216 §4.3.4.3.
622    pub discontinuous: bool,
623    /// Low-Latency HLS partial segments of this segment (RFC 8216bis §4.4.4.9).
624    /// Rendered as `#EXT-X-PART` lines *before* this segment's `#EXTINF`, but
625    /// only when the playlist is low-latency (see [`MediaPlaylist::low_latency`]).
626    /// Empty for a non-low-latency playlist or a segment whose parts have already
627    /// been coalesced into the full `#EXTINF`.
628    pub parts: Vec<PartSpec>,
629    /// `#EXT-X-BYTERANGE` (RFC 8216bis §4.4.4.2) — this segment is a
630    /// sub-range of the resource named by [`Self::uri`]. Rendered
631    /// immediately after this segment's `#EXTINF` line, before the URI.
632    /// `None` (the default) means the segment is the entire resource.
633    pub byte_range: Option<ByteRange>,
634    /// `#EXT-X-MAP` (RFC 8216bis §4.4.4.5) applying to this segment. Per
635    /// spec a map applies to every segment following it until the next
636    /// `#EXT-X-MAP`; `to_m3u8` renders the tag only when it differs from the
637    /// previous segment's map (dedup), and [`MediaPlaylist::parse`] carries
638    /// the value forward onto every segment it applies to — so this field
639    /// is `Some` on every segment covered by a given `#EXT-X-MAP`, not just
640    /// the one it was written before.
641    pub map: Option<MapTag>,
642    /// `#EXT-X-GAP` (RFC 8216bis §4.4.4.7) — `true` if this segment's URI
643    /// does not contain media data and should not be loaded by clients.
644    /// Applies to exactly the one segment it is rendered against (no
645    /// carry-forward, unlike [`Self::map`]/[`Self::bitrate`]).
646    pub gap: bool,
647    /// `#EXT-X-BITRATE` (RFC 8216bis §4.4.4.8) in kilobits per second —
648    /// carried forward (same rule as [`Self::map`]) onto every segment
649    /// following the tag until the next `#EXT-X-BITRATE` or the end of the
650    /// Playlist. `to_m3u8` re-emits the tag only when it changes from the
651    /// previous segment (same dedup rule as `#EXT-X-MAP`). The spec says the
652    /// tag does not apply to a segment carrying its own `#EXT-X-BYTERANGE`;
653    /// this crate does not enforce that producer-side constraint (documented
654    /// modeling gap, see the module docs).
655    pub bitrate: Option<u64>,
656    /// The `#EXTINF` title: the text after the duration's comma
657    /// (RFC 8216bis §4.4.4.1 — "a comma-separated title string, the
658    /// title of the Media Segment ... optional"; the spec notes it
659    /// "applies to the entire segment, but ... is normally not displayed").
660    /// `None` when the tag carried no title (the overwhelmingly common
661    /// case), which renders as the bare `#EXTINF:<dur>,` — audit BH-W11
662    /// (#1111): the title used to be discarded outright, so it
663    /// survived no round trip.
664    pub title: Option<String>,
665    /// Verbatim tag lines that appeared immediately before this segment's
666    /// defining line(s) and *define this segment* — `#EXT-X-KEY`,
667    /// `#EXT-X-PROGRAM-DATE-TIME`, `#EXT-X-DATERANGE`, `#EXT-X-CUE-*`, or
668    /// any other tag this crate does not model with a typed field
669    /// (RFC 8216bis §4.4.1: such tags apply until the next occurrence or the
670    /// end of the Playlist, so position is load-bearing). `to_m3u8` re-emits
671    /// them here, in order, immediately before the segment — so a key
672    /// rotation or a per-segment PDT survives parse → render (audit BH-W5,
673    /// issue #1111; previously all such lines were hoisted into one
674    /// playlist-level `extra_tags` block before every segment, which applied
675    /// the last key to the whole playlist and collapsed the PDT timeline).
676    /// `MediaPlaylist::parse` populates this only for tags it recognizes as
677    /// segment-scoped; unrecognized *playlist-level* tags
678    /// (`#EXT-X-MEDIA`, `#EXT-X-DEFINE`, …) still go to
679    /// [`MediaPlaylist::extra_tags`].
680    pub pre_tags: Vec<String>,
681    /// `#EXT-X-PROGRAM-DATE-TIME` (RFC 8216bis §4.4.4.6) associated with
682    /// this segment, if any.
683    pub program_date_time: Option<ProgramDateTime>,
684}
685
686/// A media playlist (`#EXTM3U` / `#EXTINF` / ...).
687#[derive(Debug, Clone, PartialEq, Default)]
688pub struct MediaPlaylist {
689    /// `#EXT-X-VERSION` — an explicit *floor*, not the rendered value. `0`
690    /// (this type's `Default`) means "no explicit floor": [`Self::to_m3u8`]
691    /// renders exactly [`Self::computed_version`], or no tag at all when
692    /// nothing triggers one. A nonzero value is raised — never lowered — to
693    /// the computed minimum. See the module's "Protocol version derivation"
694    /// docs (issue #871).
695    pub version: u8,
696    /// `#EXT-X-TARGETDURATION` — must be >= the max rounded segment duration.
697    pub target_duration: u32,
698    /// `#EXT-X-MEDIA-SEQUENCE`
699    pub media_sequence: u64,
700    /// `#EXT-X-DISCONTINUITY-SEQUENCE` (RFC 8216 §4.3.3.3) — the count of
701    /// discontinuities that have already rolled off the front of a live
702    /// sliding-window playlist and are no longer represented by any in-window
703    /// `#EXT-X-DISCONTINUITY` tag. Emitted as
704    /// `#EXT-X-DISCONTINUITY-SEQUENCE:<n>` when `n > 0`; omitted when `0`
705    /// (which is the implicit default per the spec).
706    pub discontinuity_sequence: u64,
707    /// If `true`, force emission of `#EXT-X-DISCONTINUITY-SEQUENCE:<n>`
708    /// even when `discontinuity_sequence == 0` (Apple authoring 8.17).
709    /// Set `true` on parse whenever the tag is present, so a parsed
710    /// `#EXT-X-DISCONTINUITY-SEQUENCE:0` round-trips byte-stably.
711    pub always_emit_discontinuity_sequence: bool,
712    /// Ordered list of segments.
713    pub segments: Vec<MediaSegment>,
714    /// The in-progress (open) segment, if any — rendered as trailing
715    /// `#EXT-X-PART` lines with no `#EXTINF` (LL-HLS live edge).
716    pub open_segment: Option<OpenSegment>,
717    /// If `true`, append `#EXT-X-ENDLIST`.
718    pub endlist: bool,
719    /// Extra tag lines emitted verbatim before segment entries
720    /// (e.g. `#EXT-X-DATERANGE:...`).
721    pub extra_tags: Vec<String>,
722    /// Low-Latency HLS configuration (RFC 8216bis). When `Some`, `to_m3u8`
723    /// renders the LL-HLS directives — `#EXT-X-SERVER-CONTROL`,
724    /// `#EXT-X-PART-INF`, each segment's `#EXT-X-PART` lines, and (if set) the
725    /// `#EXT-X-PRELOAD-HINT`. When `None` (the default), none of these appear —
726    /// LL-HLS is strictly opt-in and a plain playlist is unchanged.
727    pub low_latency: Option<LowLatencyConfig>,
728    /// If `true`, emit `#EXT-X-I-FRAMES-ONLY` (RFC 8216 §4.3.3.6) in the
729    /// header block, declaring that every segment in this playlist carries a
730    /// single I-frame (a trick-play / thumbnail rendition).  When `true` the
731    /// rendered version is at least 4 (RFC 8216 §4.3.3.6 requirement).
732    pub iframes_only: bool,
733    /// `#EXT-X-RENDITION-REPORT` entries (RFC 8216bis §4.4.5.4) — one per
734    /// associated Rendition, pointing an LL-HLS client at that Rendition's
735    /// current playlist state. Rendered after the segment list (and any
736    /// preload hint), in order.
737    pub rendition_reports: Vec<RenditionReport>,
738    /// `#EXT-X-SKIP` (RFC 8216bis §4.4.5.2) — present on a Playlist Delta
739    /// Update response, replacing the segments/tags before the Skip
740    /// Boundary. `None` (the default) means this is a full playlist, not a
741    /// delta update.
742    pub skip: Option<SkipInfo>,
743    /// `#EXT-X-INDEPENDENT-SEGMENTS` (RFC 8216bis §4.4.2.1) — every Media
744    /// Segment in this Playlist can be decoded without information from any
745    /// other segment.
746    pub independent_segments: bool,
747    /// `#EXT-X-START` (RFC 8216bis §4.4.2.2) — a preferred playback start
748    /// point.
749    pub start: Option<StartPoint>,
750    /// `#EXT-X-DEFINE` entries (RFC 8216bis §4.4.2.3) — variable
751    /// declarations/imports, in the order they appeared on the wire.
752    pub defines: Vec<Define>,
753    /// `#EXT-X-PLAYLIST-TYPE` (RFC 8216bis §4.4.3.5) — mutability
754    /// declaration. `None` means the tag was absent (no additional
755    /// restriction beyond Section 6.2.1's defaults).
756    pub playlist_type: Option<PlaylistType>,
757}
758
759/// Low-Latency HLS playlist configuration — RFC 8216bis.
760///
761/// Presence of this config on a [`MediaPlaylist`] switches on the LL-HLS
762/// directives (`#EXT-X-SERVER-CONTROL`, `#EXT-X-PART-INF`, `#EXT-X-PART`,
763/// `#EXT-X-PRELOAD-HINT`); see the module docs for each tag's spec section.
764#[derive(Debug, Clone, PartialEq)]
765pub struct LowLatencyConfig {
766    /// Part-target duration in seconds — the `PART-TARGET` of `#EXT-X-PART-INF`
767    /// (RFC 8216bis §4.4.3.7). Typically 0.2–0.5 s. `None` means no
768    /// `#EXT-X-PART-INF` tag is present or rendered — the shape of a playlist
769    /// that carries `#EXT-X-SERVER-CONTROL` (e.g. `HOLD-BACK` or
770    /// `CAN-SKIP-UNTIL`) without Partial Segments, which RFC 8216bis §4.4.3.8
771    /// allows (audit BH-W3, issue #1111: this used to be a non-optional
772    /// `DecimalSeconds` that re-rendered a bogus `PART-TARGET=0`).
773    pub part_target: Option<DecimalSeconds>,
774    /// `PART-HOLD-BACK` in seconds — the `#EXT-X-SERVER-CONTROL` attribute
775    /// (RFC 8216bis §4.4.3.8). MUST be at least `3 × part_target`; the
776    /// renderer raises it to that floor if a smaller value is supplied.
777    /// REQUIRED if the Playlist contains `#EXT-X-PART-INF` (§4.4.3.8), so
778    /// `to_m3u8` renders it exactly when this is `Some` — as with
779    /// [`Self::part_target`], `None` emits nothing (audit BH-W3).
780    pub part_hold_back: Option<DecimalSeconds>,
781    /// URI of the next, not-yet-available part or map — rendered as
782    /// `#EXT-X-PRELOAD-HINT:TYPE=<...>,URI="<uri>"` (RFC 8216bis §4.4.5.3). When
783    /// `None`, no preload hint is emitted (e.g. an ended playlist).
784    pub preload_hint_part: Option<String>,
785    /// `TYPE` of [`Self::preload_hint_part`]'s hinted resource (RFC 8216bis
786    /// §4.4.5.3): `PART` (a Partial Segment) or `MAP` (a Media
787    /// Initialization Section). Only meaningful when `preload_hint_part` is
788    /// `Some`; defaults to [`PreloadHintType::Part`] (the overwhelmingly
789    /// common case).
790    pub preload_hint_type: PreloadHintType,
791    /// `BYTERANGE-START` of the `#EXT-X-PRELOAD-HINT` tag (RFC 8216bis
792    /// §4.4.5.3) — byte offset of the hinted resource. `None` implies 0.
793    pub preload_hint_byte_range_start: Option<u64>,
794    /// `BYTERANGE-LENGTH` of the `#EXT-X-PRELOAD-HINT` tag (RFC 8216bis
795    /// §4.4.5.3) — length in bytes. `None` means "to the end of the
796    /// resource".
797    pub preload_hint_byte_range_length: Option<u64>,
798    /// `CAN-SKIP-UNTIL` attribute of `#EXT-X-SERVER-CONTROL` (RFC 8216bis
799    /// §4.4.3.8) — the Skip Boundary in seconds, advertising support for
800    /// Playlist Delta Updates (`#EXT-X-SKIP`). `None` omits the attribute.
801    pub can_skip_until: Option<DecimalSeconds>,
802    /// `CAN-BLOCK-RELOAD` attribute of `#EXT-X-SERVER-CONTROL` (RFC 8216bis
803    /// §4.4.3.8) — whether the server supports Blocking Playlist Reload
804    /// (RFC 8216bis §6.2.5.2). `to_m3u8` renders the actual value
805    /// (`YES`/`NO`) rather than assuming `YES`; `MediaPlaylist::parse`
806    /// derives it from the attribute's real wire value, defaulting to
807    /// `false` (per RFC 8216bis) when the attribute — or the whole
808    /// `#EXT-X-SERVER-CONTROL` tag — is absent. A client MUST NOT infer
809    /// blocking-reload support merely from [`MediaPlaylist::low_latency`]
810    /// being `Some`; it must check this field (issue #717 slice 1 gap).
811    pub can_block_reload: bool,
812    /// Unmodeled attributes from `#EXT-X-SERVER-CONTROL` (`#EXT-X-SERVER-CONTROL`
813    /// is §4.4.3.8), retained so `REQ-` prefixed names can fire RFC 8216bis §8
814    /// row 12. Sorted by name on parse (deterministic). The three per-tag
815    /// lists below are the *only* copy of each tag's unmodeled attributes
816    /// (audit BH-W8, issue #1111): this struct used to also carry a
817    /// playlist-wide `extra_attrs` union that the renderer ignored, so a
818    /// hand-built config could put a `REQ-` attribute in the union only and
819    /// have it (a) never rendered and (b) missed by the §8 row-12 version
820    /// scan, under-declaring `EXT-X-VERSION`.
821    pub sc_extra_attrs: Vec<(String, AttrValue)>,
822    /// Unmodeled attributes from `#EXT-X-PART-INF` only.
823    pub pi_extra_attrs: Vec<(String, AttrValue)>,
824    /// Unmodeled attributes from `#EXT-X-PRELOAD-HINT` only.
825    pub ph_extra_attrs: Vec<(String, AttrValue)>,
826    /// `HOLD-BACK` attribute of `#EXT-X-SERVER-CONTROL` (RFC 8216bis
827    /// §4.4.3.8) — the server-recommended minimum distance from the live
828    /// edge for clients NOT playing in Low-Latency Mode. `None` means the
829    /// attribute is absent (the spec default: three times the Target
830    /// Duration). When `Some`, the value MUST be at least three times the
831    /// Target Duration per the spec. Only meaningful when
832    /// [`LowLatencyConfig`] is present; for a non-LL-HLS playlist with a
833    /// custom hold-back, set this on a playlist that also carries the
834    /// `#EXT-X-PART-INF` and `#EXT-X-SERVER-CONTROL` tags.
835    pub hold_back: Option<DecimalSeconds>,
836    /// `CAN-SKIP-DATERANGES` attribute of `#EXT-X-SERVER-CONTROL`
837    /// (RFC 8216bis §4.4.3.8) — enumerated-string `YES` if the server can
838    /// produce Playlist Delta Updates (§6.2.5.1) that skip older
839    /// `#EXT-X-DATERANGE` tags in addition to Media Segments. REQUIRES the
840    /// presence of [`Self::can_skip_until`]; the renderer suppresses this
841    /// attribute when that field is `None` regardless of this value.
842    pub can_skip_dateranges: bool,
843}
844
845impl LowLatencyConfig {
846    /// The `PART-HOLD-BACK` value actually rendered: at least
847    /// `3 × part_target` per RFC 8216bis §4.4.3.8, even if
848    /// [`Self::part_hold_back`] is smaller. When [`Self::part_target`] is
849    /// `None` there is no floor to compute, so [`Self::part_hold_back`] is
850    /// returned as given: a playlist without Partial Segments may still carry
851    /// the attribute so its `#EXT-X-SERVER-CONTROL` equals the other Media
852    /// Playlists' (RFC 8216bis §6.2.4).
853    pub fn effective_part_hold_back(&self) -> Option<DecimalSeconds> {
854        let Some(part_target) = self.part_target else {
855            return self.part_hold_back;
856        };
857        let floor = 3.0 * part_target.get();
858        // `floor` and `self.part_hold_back.get()` are both finite and
859        // non-negative, so their max is too.
860        Some(DecimalSeconds::new_unchecked(
861            self.part_hold_back
862                .map_or(floor, |phb| phb.get().max(floor)),
863        ))
864    }
865}
866
867impl Default for LowLatencyConfig {
868    /// Defaults `can_block_reload` to `true` — this crate's own LL-HLS
869    /// origin (and every in-repo test/fixture that builds a
870    /// [`LowLatencyConfig`] from scratch via `..Default::default()`) always
871    /// supports blocking reload, matching `to_m3u8()`'s historical
872    /// `CAN-BLOCK-RELOAD=YES` output. This default is never consulted for a
873    /// *parsed* playlist: [`MediaPlaylist::parse`] always derives
874    /// `can_block_reload` from the wire attribute (RFC 8216bis default:
875    /// `false`/absent-means-NO), independent of this `Default` impl.
876    fn default() -> Self {
877        Self {
878            part_target: None,
879            part_hold_back: None,
880            preload_hint_part: None,
881            preload_hint_type: PreloadHintType::default(),
882            preload_hint_byte_range_start: None,
883            preload_hint_byte_range_length: None,
884            can_skip_until: None,
885            can_block_reload: true,
886            sc_extra_attrs: Vec::new(),
887            pi_extra_attrs: Vec::new(),
888            ph_extra_attrs: Vec::new(),
889            hold_back: None,
890            can_skip_dateranges: false,
891        }
892    }
893}
894
895// ---------------------------------------------------------------------------
896// Protocol Version Compatibility — RFC 8216bis §8 (issue #871).
897//
898// Source of truth: `docs/version-compatibility.md`, a twice-verified
899// transcription of §8 from draft-pantos-hls-rfc8216bis-22. One named
900// constant per row of that table, cited by row number. `#EXT-X-VERSION` is
901// always `max()` over the minimums the playlist's actual content triggers —
902// never a value picked ahead of time and baked in (the bug this issue
903// fixes: a low-latency origin unconditionally declaring 9 when nothing it
904// emits requires more than 6, forcing every client on 6/7/8 to refuse a
905// stream it could have played).
906// ---------------------------------------------------------------------------
907
908/// §8 row 2 (Media Playlist): the `IV` attribute of `EXT-X-KEY`.
909const VERSION_KEY_IV: u8 = 2;
910/// §8 row 3 (Media Playlist): a floating-point `EXTINF` duration.
911const VERSION_FLOAT_EXTINF: u8 = 3;
912/// §8 row 4 (Media Playlist): `EXT-X-BYTERANGE`, or `EXT-X-I-FRAMES-ONLY`.
913const VERSION_BYTERANGE_OR_IFRAMES_ONLY: u8 = 4;
914/// §8 row 5 (Media Playlist): `EXT-X-KEY` with `METHOD=SAMPLE-AES`, or its
915/// `KEYFORMAT`/`KEYFORMATVERSIONS` attributes, or `EXT-X-MAP` **together
916/// with** `EXT-X-I-FRAMES-ONLY`. (`EXT-X-MAP` alone, without
917/// `EXT-X-I-FRAMES-ONLY`, needs [`VERSION_MAP_WITHOUT_IFRAMES_ONLY`] = 6
918/// instead — the row-6 note in `docs/version-compatibility.md`.)
919const VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY: u8 = 5;
920/// §8 row 6 (Media Playlist): `EXT-X-MAP` in a playlist that does *not* also
921/// carry `EXT-X-I-FRAMES-ONLY`.
922const VERSION_MAP_WITHOUT_IFRAMES_ONLY: u8 = 6;
923/// §8 row 7 (Multivariant Playlist): a `"SERVICE"` value for the
924/// `INSTREAM-ID` attribute of `EXT-X-MEDIA`.
925const VERSION_MEDIA_SERVICE_INSTREAM_ID: u8 = 7;
926/// §8 row 8 (any Playlist): variable substitution.
927const VERSION_VARIABLE_SUBSTITUTION: u8 = 8;
928/// §8 row 9 (any Playlist): `EXT-X-SKIP`.
929const VERSION_SKIP: u8 = 9;
930/// §8 row 10 (any Playlist): an `EXT-X-SKIP` that replaces
931/// `EXT-X-DATERANGE` tags in a Playlist Delta Update — its
932/// `RECENTLY-REMOVED-DATERANGES` attribute is non-empty.
933const VERSION_SKIP_REPLACES_DATERANGE: u8 = 10;
934/// §8 row 11 (any Playlist): `EXT-X-DEFINE` with a `QUERYPARAM` attribute.
935const VERSION_DEFINE_QUERYPARAM: u8 = 11;
936/// §8 row 12 (any Playlist): an attribute whose name starts with `"REQ-"`.
937const VERSION_REQ_ATTRIBUTE: u8 = 12;
938/// §8 row 13 (Multivariant Playlist): `EXT-X-MEDIA` with an `INSTREAM-ID`
939/// attribute for a non-`CLOSED-CAPTIONS` `TYPE`.
940const VERSION_MEDIA_INSTREAM_ID_NON_CC: u8 = 13;
941
942/// RFC 8216bis §4.2.2's variable-substitution marker (`{$name}`, inside a
943/// quoted-string attribute value or a URI) — §8 row 8's trigger. This is
944/// *not* part of the version-compatibility transcription (that section
945/// states only the rule, not the substitution grammar, which lives in a
946/// different part of the spec) — it is a textual heuristic over the opaque
947/// strings this crate already carries (`extra_tags`, segment/variant URIs),
948/// not a modeled `EXT-X-DEFINE` parser.
949const VARIABLE_SUBSTITUTION_MARKER: &str = "{$";
950
951/// Fold `n` into the running maximum `v` — §8's rule is that the highest
952/// version required by any single triggered feature governs the whole
953/// Playlist.
954fn bump_version(v: &mut Option<u8>, n: u8) {
955    *v = Some(match *v {
956        Some(m) => m.max(n),
957        None => n,
958    });
959}
960
961/// `true` if this duration **renders** as a floating-point `EXTINF` value
962/// (§8 row 3).
963///
964/// Deliberately defined as "does [`format_extinf`] emit a decimal point",
965/// not as a numeric property of `duration`, because §8 row 3 constrains what
966/// the *playlist contains* — "A Media Playlist MUST indicate an
967/// EXT-X-VERSION of 3 or higher if it contains: Floating-point EXTINF
968/// duration values" — not what the in-memory type happens to be. Deriving
969/// the predicate from the renderer makes the two impossible to diverge.
970///
971/// They previously did, in both directions, and both were spec violations:
972///
973/// - a whole `4.0` rendered as `#EXTINF:4.000,` — a floating-point value —
974///   while this predicate (integer-millisecond) called it integral, so no
975///   `EXT-X-VERSION` was emitted at all and a v1/v2 client was told the
976///   playlist was compatible with it before meeting a float it cannot parse;
977/// - a sub-millisecond `4.0004` rendered at full precision as
978///   `#EXTINF:4.0004,` (correctly, since issue #872) while the same
979///   millisecond-granular rounding still called it integral.
980///
981/// Note the modeling boundary this implies: a playlist *parsed* from text
982/// that literally said `4.000` reports no row-3 requirement here, because
983/// [`MediaPlaylist`] stores the numeric duration, not its original lexical
984/// form — and this crate would re-render it as `4`. The claim is about the
985/// playlist this crate emits, which is the one a client will actually read.
986fn is_fractional_duration(duration: DecimalSeconds) -> bool {
987    format_extinf(duration).contains('.')
988}
989
990/// `true` if `s` carries a variable-substitution reference (§8 row 8).
991fn contains_variable_substitution(s: &str) -> bool {
992    s.contains(VARIABLE_SUBSTITUTION_MARKER)
993}
994
995/// Scan a set of opaque, verbatim tag lines (a playlist's `extra_tags`) for
996/// the §8 triggers that are attributes of tags this crate does not model as
997/// struct fields: `EXT-X-KEY`'s `IV`/`METHOD`/`KEYFORMAT*` attributes (rows
998/// 2 and 5), `EXT-X-MEDIA`'s `INSTREAM-ID` (rows 7 and 13 —
999/// Multivariant-Playlist-only; harmless to scan on a Media Playlist's
1000/// `extra_tags` since that tag never legitimately appears there), and any
1001/// attribute name starting `REQ-` (row 12). Returns the max triggered
1002/// version, or `None`.
1003///
1004/// **Row 11 is handled here only for the raw-line path.** `EXT-X-DEFINE`
1005/// gained a typed representation in issue #872, so a *parsed* playlist's
1006/// `EXT-X-DEFINE` tags land in `defines` and never reach `extra_tags`;
1007/// relying on this scan alone would make row 11 silently stop firing for
1008/// every parsed or programmatically-built playlist. Both `computed_version`
1009/// impls therefore check the typed field, and the arm below is retained
1010/// purely for a caller who hand-pushes a verbatim tag line. `bump_version`
1011/// is a max, so the two paths cannot double-count or disagree.
1012///
1013///
1014/// **Row 12 now fires on typed tags too.** Unknown attributes on modeled tags
1015/// are retained since issue #884 (`extra_attrs` on every attribute-list-bearing
1016/// struct); the typed check in `any_typed_req_attr` and
1017/// `any_media_typed_req_attr` scans those fields so `REQ-` attributes reach
1018/// the version derivation whether they appear on a modeled or an unmodeled
1019/// tag.
1020fn scan_tag_lines_for_version(tags: &[String]) -> Option<u8> {
1021    let mut v: Option<u8> = None;
1022    for tag in tags {
1023        if let Some(rest) = tag.strip_prefix("#EXT-X-KEY:") {
1024            let (attrs, _quoted) = parse_attribute_list(rest);
1025            if attrs.contains_key("IV") {
1026                bump_version(&mut v, VERSION_KEY_IV);
1027            }
1028            if attrs.get("METHOD").map(String::as_str) == Some("SAMPLE-AES")
1029                || attrs.contains_key("KEYFORMAT")
1030                || attrs.contains_key("KEYFORMATVERSIONS")
1031            {
1032                bump_version(
1033                    &mut v,
1034                    VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY,
1035                );
1036            }
1037        } else if let Some(rest) = tag.strip_prefix("#EXT-X-DEFINE:") {
1038            // Row 11 via the *raw-line* path only. Since issue #872 a parsed
1039            // `EXT-X-DEFINE` lands in the typed `defines` field and never
1040            // reaches here, so this arm no longer covers the common case —
1041            // both `computed_version` impls check `defines` directly. It is
1042            // kept because a caller may still hand-push a verbatim tag line
1043            // into `extra_tags`, and `bump_version` is a max, so the two
1044            // paths cannot double-count or disagree.
1045            if parse_attribute_list(rest).0.contains_key("QUERYPARAM") {
1046                bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
1047            }
1048        } else if let Some(rest) = tag.strip_prefix("#EXT-X-MEDIA:") {
1049            let (attrs, _quoted) = parse_attribute_list(rest);
1050            if let Some(instream_id) = attrs.get("INSTREAM-ID") {
1051                if instream_id.starts_with("SERVICE") {
1052                    bump_version(&mut v, VERSION_MEDIA_SERVICE_INSTREAM_ID);
1053                }
1054                let is_closed_captions =
1055                    attrs.get("TYPE").map(String::as_str) == Some("CLOSED-CAPTIONS");
1056                if !is_closed_captions {
1057                    bump_version(&mut v, VERSION_MEDIA_INSTREAM_ID_NON_CC);
1058                }
1059            }
1060        }
1061        // Row 12 applies to ANY attribute-bearing tag, not just the three
1062        // handled above — scan every tag's attribute keys uniformly.
1063        if let Some(colon) = tag.find(':')
1064            && parse_attribute_list(&tag[colon + 1..])
1065                .0
1066                .keys()
1067                .any(|k| k.starts_with("REQ-"))
1068        {
1069            bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
1070        }
1071    }
1072    v
1073}
1074
1075/// Check whether any extra_attrs on any typed struct carry a `REQ-` prefix
1076/// (RFC 8216bis §8 row 12). `start` is Option<StartPoint>, `defines` is
1077/// `&[Define]`, `sd` is `&[SessionData]`, `sk` is `&[SessionKey]`,
1078/// `cs` is `Option<&ContentSteering>`, `variants` is `&[Variant]`,
1079/// `iframes` is `&[IFrameVariant]`.
1080///
1081/// AttributeName contains only uppercase letters per §4.2, so the `REQ-`
1082/// prefix match is case-sensitive.
1083#[allow(clippy::too_many_arguments)]
1084fn any_typed_req_attr(
1085    start: Option<&StartPoint>,
1086    defines: &[Define],
1087    sd: &[SessionData],
1088    sk: &[SessionKey],
1089    cs: Option<&ContentSteering>,
1090    media: &[MediaRendition],
1091    variants: &[Variant],
1092    iframes: &[IFrameVariant],
1093) -> bool {
1094    let extra_attr_is_req = |attrs: &[(String, AttrValue)]| -> bool {
1095        attrs.iter().any(|(k, _)| k.starts_with("REQ-"))
1096    };
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 media.iter().any(|m| extra_attr_is_req(&m.extra_attrs)) {
1120        return true;
1121    }
1122    if variants.iter().any(|v| extra_attr_is_req(&v.extra_attrs)) {
1123        return true;
1124    }
1125    if iframes.iter().any(|i| extra_attr_is_req(&i.extra_attrs)) {
1126        return true;
1127    }
1128    false
1129}
1130
1131/// Check whether any extra_attrs on media-playlist-specific typed structs
1132/// carry a `REQ-` prefix: `MapTag` (in segments/open), `PartSpec`,
1133/// `RenditionReport`, `SkipInfo`, `LowLatencyConfig`.
1134fn any_media_typed_req_attr(
1135    segments: &[MediaSegment],
1136    open_segment: Option<&OpenSegment>,
1137    rendition_reports: &[RenditionReport],
1138    skip: Option<&SkipInfo>,
1139    low_latency: Option<&LowLatencyConfig>,
1140) -> bool {
1141    let extra_attr_is_req = |attrs: &[(String, AttrValue)]| -> bool {
1142        attrs.iter().any(|(k, _)| k.starts_with("REQ-"))
1143    };
1144    if segments.iter().any(|s| {
1145        s.map
1146            .as_ref()
1147            .is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
1148            || s.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs))
1149    }) {
1150        return true;
1151    }
1152    if let Some(open) = open_segment
1153        && (open
1154            .map
1155            .as_ref()
1156            .is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
1157            || open.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs)))
1158    {
1159        return true;
1160    }
1161    if rendition_reports
1162        .iter()
1163        .any(|r| extra_attr_is_req(&r.extra_attrs))
1164    {
1165        return true;
1166    }
1167    if skip.is_some_and(|s| extra_attr_is_req(&s.extra_attrs)) {
1168        return true;
1169    }
1170    if low_latency.is_some_and(|ll| {
1171        extra_attr_is_req(&ll.sc_extra_attrs)
1172            || extra_attr_is_req(&ll.pi_extra_attrs)
1173            || extra_attr_is_req(&ll.ph_extra_attrs)
1174    }) {
1175        return true;
1176    }
1177    false
1178}
1179
1180/// Shared floor/clamp logic behind `MediaPlaylist`'s and `MasterPlaylist`'s
1181/// private `effective_version` methods: `explicit` (the public `version`
1182/// field) acts as a floor over `computed` (the derived minimum), raised —
1183/// never lowered — to it. `0` means "no explicit floor".
1184fn effective_version(explicit: u8, computed: Option<u8>) -> Option<u8> {
1185    match (explicit, computed) {
1186        (0, None) => None,
1187        (0, Some(m)) => Some(m),
1188        (e, None) => Some(e),
1189        (e, Some(m)) => Some(e.max(m)),
1190    }
1191}
1192
1193impl MediaPlaylist {
1194    /// Compute the minimum `#EXT-X-VERSION` this playlist's actual content
1195    /// requires, per RFC 8216bis §8 (`docs/version-compatibility.md`).
1196    /// `None` means the playlist is fully compatible with version 1, which
1197    /// per §8's opening rule need not carry the tag at all.
1198    ///
1199    /// This is `max()` over every triggered rule — see the `VERSION_*`
1200    /// constants above for the rule → row mapping. [`Self::to_m3u8`] uses
1201    /// this (via the private `effective_version`) rather than blindly
1202    /// trusting [`Self::version`]; see that field's docs for how an
1203    /// explicit value interacts with this computed minimum.
1204    pub fn computed_version(&self) -> Option<u8> {
1205        let mut v: Option<u8> = None;
1206
1207        // Rows 5/6: EXT-X-MAP, present either as a structured
1208        // MediaSegment/OpenSegment map, or (a caller that hasn't adopted
1209        // that field, e.g. a raw #EXT-X-MAP: line) in `extra_tags`.
1210        let has_map = self.segments.iter().any(|s| s.map.is_some())
1211            // The open segment's map renders only with low-latency on, so it
1212            // only counts then (audit BH-O3, #1111).
1213            || (self.low_latency.is_some()
1214                && self.open_segment.as_ref().is_some_and(|o| o.map.is_some()))
1215            || self.extra_tags.iter().any(|t| t.starts_with("#EXT-X-MAP:"));
1216        if has_map {
1217            bump_version(
1218                &mut v,
1219                if self.iframes_only {
1220                    VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY
1221                } else {
1222                    VERSION_MAP_WITHOUT_IFRAMES_ONLY
1223                },
1224            );
1225        }
1226
1227        // Row 4: EXT-X-BYTERANGE, or EXT-X-I-FRAMES-ONLY.
1228        if self.iframes_only || self.segments.iter().any(|s| s.byte_range.is_some()) {
1229            bump_version(&mut v, VERSION_BYTERANGE_OR_IFRAMES_ONLY);
1230        }
1231
1232        // Row 3: floating-point EXTINF duration.
1233        if self
1234            .segments
1235            .iter()
1236            .any(|s| is_fractional_duration(s.duration))
1237        {
1238            bump_version(&mut v, VERSION_FLOAT_EXTINF);
1239        }
1240
1241        // Rows 9/10: EXT-X-SKIP, optionally replacing EXT-X-DATERANGE.
1242        if let Some(skip) = &self.skip {
1243            bump_version(&mut v, VERSION_SKIP);
1244            if !skip.recently_removed_daterange_ids.is_empty() {
1245                bump_version(&mut v, VERSION_SKIP_REPLACES_DATERANGE);
1246            }
1247        }
1248
1249        // Row 11: EXT-X-DEFINE with a QUERYPARAM attribute. Read from the
1250        // typed `defines` (issue #872) — before that, `EXT-X-DEFINE` was
1251        // unmodeled and this row could only be reached by string-scanning
1252        // `extra_tags`, which now never sees the tag at all.
1253        if self
1254            .defines
1255            .iter()
1256            .any(|d| matches!(d, Define::QueryParam { .. }))
1257        {
1258            bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
1259        }
1260
1261        // Row 8: variable substitution, wherever this crate carries a URI
1262        // or tag verbatim. Every string field that can legitimately hold a
1263        // `{$var}` reference is scanned — a URI or attribute value the
1264        // caller supplied, whether it reached us as a modeled field or as
1265        // an opaque `extra_tags` line.
1266        if self
1267            .extra_tags
1268            .iter()
1269            .any(|t| contains_variable_substitution(t))
1270            || self
1271                .segments
1272                .iter()
1273                .any(|s| contains_variable_substitution(&s.uri))
1274            || self.media_playlist_typed_strings_use_substitution()
1275        {
1276            bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
1277        }
1278
1279        // Rows 2/5/7/12/13: EXT-X-KEY's IV/METHOD/KEYFORMAT*, EXT-X-MEDIA's
1280        // INSTREAM-ID, and any REQ- attribute on *unmodeled* tags — none of
1281        // these tags has a modeled struct field in this crate, so `extra_tags`
1282        // remains the substrate for the first three. Row 12 on modeled tags
1283        // is now checked below via the typed extra_attrs scan (issue #884).
1284        // (Row 11 moved to the typed check above.)
1285        // Since audit BH-W5 (issue #1111) segment-defining tags live on
1286        // `MediaSegment::pre_tags`/`OpenSegment::pre_tags` instead of the
1287        // playlist-level block, so the scan must cover those too — an
1288        // `EXT-X-KEY` with `IV=` before segment 2 still triggers row 2.
1289        {
1290            let mut opaque_tags: Vec<&String> = self.extra_tags.iter().collect();
1291            opaque_tags.extend(self.segments.iter().flat_map(|s| &s.pre_tags));
1292            if let Some(open) = &self.open_segment {
1293                opaque_tags.extend(open.pre_tags.iter());
1294            }
1295            let owned: Vec<String> = opaque_tags.into_iter().cloned().collect();
1296            if let Some(m) = scan_tag_lines_for_version(&owned) {
1297                bump_version(&mut v, m);
1298            }
1299        }
1300
1301        // Row 12: REQ- attribute on any typed struct — modeled tags that
1302        // carry extra_attrs fields since issue #884.
1303        if any_media_typed_req_attr(
1304            &self.segments,
1305            // Open-segment parts/map render only with low-latency on.
1306            self.open_segment
1307                .as_ref()
1308                .filter(|_| self.low_latency.is_some()),
1309            &self.rendition_reports,
1310            self.skip.as_ref(),
1311            self.low_latency.as_ref(),
1312        ) || any_typed_req_attr(
1313            self.start.as_ref(),
1314            &self.defines,
1315            &[],
1316            &[],
1317            None,
1318            &[],
1319            &[],
1320            &[],
1321        ) {
1322            bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
1323        }
1324
1325        v
1326    }
1327
1328    /// Row 8 helper: does any *typed* string field of this Media Playlist
1329    /// carry a `{$var}` reference?
1330    ///
1331    /// Segment URIs are checked by the caller; this covers the rest of the
1332    /// places a URI or attribute value lives once it is modeled rather than
1333    /// left in `extra_tags` — `EXT-X-MAP`/`EXT-X-PART` URIs (typed since
1334    /// before #872) and the `EXT-X-DEFINE` values / preload-hint /
1335    /// rendition-report URIs. Missing one of these would under-declare the
1336    /// version for a playlist that genuinely uses substitution.
1337    fn media_playlist_typed_strings_use_substitution(&self) -> bool {
1338        let seg_strings = self.segments.iter().flat_map(|s| {
1339            s.map
1340                .iter()
1341                .map(|m| &m.uri)
1342                .chain(s.parts.iter().map(|p| &p.uri))
1343        });
1344        let open_strings = self.open_segment.iter().flat_map(|o| {
1345            o.map
1346                .iter()
1347                .map(|m| &m.uri)
1348                .chain(o.parts.iter().map(|p| &p.uri))
1349        });
1350        let define_values = self.defines.iter().filter_map(|d| match d {
1351            Define::Name { value, .. } => Some(value),
1352            _ => None,
1353        });
1354        let report_uris = self.rendition_reports.iter().map(|r| &r.uri);
1355        let preload = self
1356            .low_latency
1357            .iter()
1358            .filter_map(|ll| ll.preload_hint_part.as_ref());
1359
1360        seg_strings
1361            .chain(open_strings)
1362            .chain(define_values)
1363            .chain(report_uris)
1364            .chain(preload)
1365            .any(|s| contains_variable_substitution(s))
1366    }
1367
1368    /// The `#EXT-X-VERSION` value actually rendered by [`Self::to_m3u8`]:
1369    /// [`Self::computed_version`], raised — never lowered — to
1370    /// [`Self::version`] when that field carries a nonzero explicit floor.
1371    /// See [`Self::version`]'s docs for the full rule.
1372    fn effective_version(&self) -> Option<u8> {
1373        effective_version(self.version, self.computed_version())
1374    }
1375}
1376
1377/// The `KEYFORMAT` an `#EXT-X-KEY` with no `KEYFORMAT` attribute implies
1378/// (RFC 8216 §4.3.2.4).
1379pub const KEYFORMAT_IDENTITY: &str = "identity";
1380
1381/// An `#EXT-X-KEY` tag (RFC 8216 §4.3.2.4): the same attribute set as
1382/// [`SessionKey`], with `METHOD=NONE` allowed.
1383pub type MediaKey = SessionKey;
1384
1385const EXT_X_KEY_PREFIX: &str = "#EXT-X-KEY:";
1386
1387fn key_format_of(key: &MediaKey) -> &str {
1388    key.keyformat.as_deref().unwrap_or(KEYFORMAT_IDENTITY)
1389}
1390
1391#[derive(Default)]
1392struct KeyState {
1393    identity: Option<MediaKey>,
1394    others: BTreeMap<String, MediaKey>,
1395}
1396
1397impl KeyState {
1398    fn apply_tags(&mut self, tags: &[String]) -> Result<()> {
1399        for tag in tags {
1400            let Some(rest) = tag.strip_prefix(EXT_X_KEY_PREFIX) else {
1401                continue;
1402            };
1403            let key = parse_media_key(rest, 0, tag)?;
1404            if key.method != EncryptionMethod::None && key.uri.is_none() {
1405                return Err(Error::KeyWithoutUri { method: key.method });
1406            }
1407            if key.method == EncryptionMethod::None {
1408                self.identity = None;
1409                self.others.clear();
1410            } else if key_format_of(&key) == KEYFORMAT_IDENTITY {
1411                self.identity = Some(key);
1412            } else {
1413                self.others.insert(key_format_of(&key).to_string(), key);
1414            }
1415        }
1416        Ok(())
1417    }
1418
1419    fn effective(&self) -> Result<Option<MediaKey>> {
1420        if let Some(key) = &self.identity {
1421            return Ok(Some(key.clone()));
1422        }
1423        match self.others.keys().next() {
1424            Some(keyformat) => Err(Error::UnsupportedKeyFormat {
1425                keyformat: keyformat.clone(),
1426            }),
1427            None => Ok(None),
1428        }
1429    }
1430}
1431
1432impl MediaPlaylist {
1433    /// The `#EXT-X-KEY` in effect for each closed segment (RFC 8216
1434    /// §4.3.2.4): a tag applies to the segment it precedes and every later
1435    /// segment until the next `#EXT-X-KEY` with the same `KEYFORMAT` or a
1436    /// `METHOD` of `NONE` (RFC 8216bis §4.4.4.4), which ends every key
1437    /// format. Only the `identity` `KEYFORMAT` (absent means identity)
1438    /// participates; `METHOD=NONE` yields `None`.
1439    ///
1440    /// # Errors
1441    /// [`Error::KeyWithoutUri`] for a non-`NONE` key with no `URI`;
1442    /// [`Error::UnsupportedKeyFormat`] for a segment to which only
1443    /// non-identity key formats apply; [`Error::HlsParse`] for a malformed
1444    /// `#EXT-X-KEY`.
1445    pub fn segment_keys(&self) -> Result<Vec<Option<MediaKey>>> {
1446        self.segment_key_results()?.into_iter().collect()
1447    }
1448
1449    /// Like [`Self::segment_keys`], but a segment to which only
1450    /// non-identity key formats apply yields its own
1451    /// [`Error::UnsupportedKeyFormat`] entry instead of failing the whole
1452    /// playlist, so the clear segments after a `METHOD=NONE` stay usable.
1453    ///
1454    /// # Errors
1455    /// [`Error::KeyWithoutUri`] or [`Error::HlsParse`] for a malformed
1456    /// `#EXT-X-KEY`, which no segment can be resolved around.
1457    pub fn segment_key_results(&self) -> Result<Vec<Result<Option<MediaKey>>>> {
1458        let mut state = KeyState::default();
1459        let mut keys = Vec::with_capacity(self.segments.len());
1460        for seg in &self.segments {
1461            state.apply_tags(&seg.pre_tags)?;
1462            keys.push(state.effective());
1463        }
1464        Ok(keys)
1465    }
1466
1467    /// The key in effect for the open (in-progress) segment, if any, under
1468    /// the same rules as [`Self::segment_keys`].
1469    ///
1470    /// # Errors
1471    /// As [`Self::segment_keys`].
1472    pub fn open_segment_key(&self) -> Result<Option<MediaKey>> {
1473        let mut state = KeyState::default();
1474        for seg in &self.segments {
1475            state.apply_tags(&seg.pre_tags)?;
1476        }
1477        if let Some(open) = &self.open_segment {
1478            state.apply_tags(&open.pre_tags)?;
1479        }
1480        state.effective()
1481    }
1482}
1483
1484impl MediaPlaylist {
1485    /// Render this media playlist as an RFC 8216 `#EXTM3U` string.
1486    ///
1487    /// Emits `#EXT-X-DISCONTINUITY-SEQUENCE:<n>` after the media-sequence
1488    /// header when `discontinuity_sequence > 0` (RFC 8216 §4.3.3.3), and
1489    /// `#EXT-X-DISCONTINUITY` immediately before the `#EXTINF` of every
1490    /// segment whose [`MediaSegment::discontinuous`] flag is `true`
1491    /// (RFC 8216 §4.3.4.3).
1492    ///
1493    /// When [`Self::iframes_only`] is `true`, emits `#EXT-X-I-FRAMES-ONLY`
1494    /// (RFC 8216 §4.3.3.6) in the header block; the version this (and every
1495    /// other triggering feature) requires is derived by
1496    /// [`Self::computed_version`] (see the module's "Protocol version
1497    /// derivation" docs, issue #871) — omitted entirely when nothing in the
1498    /// playlist triggers a version requirement.
1499    ///
1500    /// # Errors
1501    ///
1502    /// A field this crate renders inside an RFC 8216 §4.2 quoted-string or
1503    /// as a URI (a segment/part/map URI, a `#EXT-X-DEFINE` `NAME`/`VALUE`,
1504    /// a `KEYFORMAT`/`SERVER-URI`, …) that contains `"`, CR or LF is
1505    /// rejected before any of it reaches the output (audit BH-W7, issue
1506    /// #1111) — those three characters are forbidden in a quoted-string and
1507    /// would let a value inject a tag line into the playlist. This renderer
1508    /// is the guard: it rejects such a value before a single byte of output
1509    /// is produced, whether the playlist came from `parse` (whose fields are
1510    /// already wire-valid) or was assembled by hand through the public
1511    /// fields.
1512    pub fn to_m3u8(&self) -> Result<String> {
1513        self.validate_for_render()?;
1514        let mut s = String::new();
1515        s.push_str("#EXTM3U\n");
1516        if let Some(version) = self.effective_version() {
1517            s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
1518        }
1519        if self.iframes_only {
1520            s.push_str("#EXT-X-I-FRAMES-ONLY\n");
1521        }
1522        // §4.4.2 tags (valid in either playlist type) — RFC 8216bis
1523        // §4.4.2.1/.2/.3 (issue #872).
1524        if self.independent_segments {
1525            s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
1526        }
1527        for def in &self.defines {
1528            push_define_line(&mut s, def)?;
1529        }
1530        if let Some(start) = &self.start {
1531            push_start_line(&mut s, start);
1532        }
1533        s.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", self.target_duration));
1534        s.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", self.media_sequence));
1535        if self.discontinuity_sequence > 0 || self.always_emit_discontinuity_sequence {
1536            s.push_str(&format!(
1537                "#EXT-X-DISCONTINUITY-SEQUENCE:{}\n",
1538                self.discontinuity_sequence
1539            ));
1540        }
1541        // §4.4.3.5 (issue #872).
1542        if let Some(pt) = self.playlist_type {
1543            s.push_str(&format!("#EXT-X-PLAYLIST-TYPE:{}\n", pt.name()));
1544        }
1545
1546        // Low-Latency HLS header directives (RFC 8216bis §4.4.3.7/§4.4.3.8),
1547        // opt-in via `low_latency`.
1548        if let Some(ll) = &self.low_latency {
1549            // #EXT-X-SERVER-CONTROL — CAN-BLOCK-RELOAD (the actual value,
1550            // not always YES) + PART-HOLD-BACK (>= 3x part-target, enforced
1551            // by effective_part_hold_back) + optional HOLD-BACK +
1552            // optional CAN-SKIP-UNTIL + optional CAN-SKIP-DATERANGES
1553            // (RFC 8216bis §4.4.3.8). PART-HOLD-BACK is REQUIRED only if the
1554            // Playlist contains #EXT-X-PART-INF, so it renders only when
1555            // present (audit BH-W3, issue #1111): a SERVER-CONTROL-only
1556            // playlist must not fabricate PART-HOLD-BACK=0.
1557            s.push_str(&format!(
1558                "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD={}",
1559                if ll.can_block_reload { "YES" } else { "NO" },
1560            ));
1561            if let Some(phb) = ll.effective_part_hold_back() {
1562                s.push_str(&format!(",PART-HOLD-BACK={}", format_secs(phb)));
1563            }
1564            if let Some(hb) = ll.hold_back {
1565                s.push_str(&format!(",HOLD-BACK={}", format_secs(hb)));
1566            }
1567            if let Some(csu) = ll.can_skip_until {
1568                s.push_str(&format!(",CAN-SKIP-UNTIL={}", format_secs(csu)));
1569                if ll.can_skip_dateranges {
1570                    s.push_str(",CAN-SKIP-DATERANGES=YES");
1571                }
1572            }
1573            render_attribute_list(&mut s, &ll.sc_extra_attrs);
1574            s.push('\n');
1575            // #EXT-X-PART-INF — the part-target duration, rendered only when
1576            // a part target exists (audit BH-W3: previously always emitted,
1577            // rendering a bogus PART-TARGET=0 for a no-parts playlist).
1578            if let Some(part_target) = ll.part_target {
1579                s.push_str(&format!(
1580                    "#EXT-X-PART-INF:PART-TARGET={}",
1581                    format_secs(part_target),
1582                ));
1583                render_attribute_list(&mut s, &ll.pi_extra_attrs);
1584                s.push('\n');
1585            }
1586        }
1587
1588        // #EXT-X-SKIP (RFC 8216bis §4.4.5.2) — a Playlist Delta Update marker
1589        // standing in for the segments/tags before the Skip Boundary.
1590        if let Some(skip) = &self.skip {
1591            s.push_str(&format!(
1592                "#EXT-X-SKIP:SKIPPED-SEGMENTS={}",
1593                skip.skipped_segments
1594            ));
1595            if !skip.recently_removed_daterange_ids.is_empty() {
1596                s.push_str(&format!(
1597                    ",RECENTLY-REMOVED-DATERANGES=\"{}\"",
1598                    skip.recently_removed_daterange_ids.join("\t")
1599                ));
1600            }
1601            render_attribute_list(&mut s, &skip.extra_attrs);
1602            s.push('\n');
1603        }
1604
1605        for tag in &self.extra_tags {
1606            s.push_str(tag);
1607            s.push('\n');
1608        }
1609
1610        for (i, seg) in self.segments.iter().enumerate() {
1611            // Segment-scoped unmodeled tags (EXT-X-KEY rotation, PDT,
1612            // DATERANGE, CUE-*) in their original position (audit BH-W5).
1613            for tag in &seg.pre_tags {
1614                s.push_str(tag);
1615                s.push('\n');
1616            }
1617            if seg.discontinuous {
1618                s.push_str("#EXT-X-DISCONTINUITY\n");
1619            }
1620            // #EXT-X-MAP (RFC 8216bis §4.4.4.5) — emitted only when it
1621            // changes from the previous segment's map, since the tag
1622            // applies "until the next EXT-X-MAP or the end of the Playlist".
1623            let prev_map = if i == 0 {
1624                None
1625            } else {
1626                self.segments[i - 1].map.as_ref()
1627            };
1628            if seg.map.as_ref() != prev_map
1629                && let Some(map) = &seg.map
1630            {
1631                push_map_line(&mut s, map)?;
1632            }
1633            // #EXT-X-BITRATE (RFC 8216bis §4.4.4.8, issue #872) — same
1634            // carry-forward + dedup-render rule as #EXT-X-MAP above.
1635            let prev_bitrate = if i == 0 {
1636                None
1637            } else {
1638                self.segments[i - 1].bitrate
1639            };
1640            if seg.bitrate != prev_bitrate
1641                && let Some(kbps) = seg.bitrate
1642            {
1643                s.push_str(&format!("#EXT-X-BITRATE:{kbps}\n"));
1644            }
1645            // #EXT-X-PROGRAM-DATE-TIME (§4.4.4.6) applies to this segment and
1646            // MUST precede its first #EXT-X-PART (§4.4.4.9); it follows
1647            // DISCONTINUITY/MAP/BITRATE, the conventional order.
1648            if let Some(pdt) = &seg.program_date_time {
1649                s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
1650            }
1651            // LL-HLS partial segments precede the parent's #EXTINF
1652            // (RFC 8216bis §4.4.4.9), rendered only for a low-latency playlist.
1653            if self.low_latency.is_some() {
1654                for part in &seg.parts {
1655                    push_part_line(&mut s, part)?;
1656                }
1657            }
1658            // #EXT-X-GAP (RFC 8216bis §4.4.4.7, issue #872) — applies to
1659            // exactly this segment; rendered immediately before its #EXTINF.
1660            if seg.gap {
1661                s.push_str("#EXT-X-GAP\n");
1662            }
1663            // Format with exactly 3 decimal places per RFC 8216 examples.
1664            match &seg.title {
1665                Some(title) => {
1666                    validate_quoted_string(title, "EXTINF title")?;
1667                    s.push_str(&format!(
1668                        "#EXTINF:{},{title}\n",
1669                        format_extinf(seg.duration)
1670                    ));
1671                }
1672                None => {
1673                    s.push_str(&format!("#EXTINF:{},\n", format_extinf(seg.duration)));
1674                }
1675            }
1676            // #EXT-X-BYTERANGE (RFC 8216bis §4.4.4.2) — after EXTINF, before
1677            // the URI it applies to.
1678            if let Some(br) = &seg.byte_range {
1679                s.push_str(&format!("#EXT-X-BYTERANGE:{}\n", br.render()));
1680            }
1681            s.push_str(&seg.uri);
1682            s.push('\n');
1683        }
1684
1685        // The in-progress (open) segment at the live edge — its parts are
1686        // known but it has not yet closed, so it carries no #EXTINF/URI
1687        // (RFC 8216bis §4.4.4.9). Its parts are rendered only for a
1688        // low-latency playlist (same opt-in gating as the closed segments'
1689        // parts above), but its segment-scoped pre-tags are always rendered
1690        // (audit BH-W5, issue #1111): the parse side only accumulates them
1691        // into an open segment when lines remain after the last URI, and
1692        // dropping them there would be the round-trip loss BH-W5 fixes.
1693        if let Some(open) = &self.open_segment {
1694            if self.low_latency.is_some() {
1695                // Same dedup-vs-previous rule as the closed segments' loop
1696                // above: `#EXT-X-MAP` applies until a new one is seen, so
1697                // only emit it here if it differs from the last *closed*
1698                // segment's map (or there were no closed segments at all).
1699                let prev_map = self.segments.last().and_then(|s| s.map.as_ref());
1700                if open.map.as_ref() != prev_map
1701                    && let Some(map) = &open.map
1702                {
1703                    push_map_line(&mut s, map)?;
1704                }
1705                // PDT precedes the open segment's first #EXT-X-PART (§4.4.4.9).
1706                if let Some(pdt) = &open.program_date_time {
1707                    s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
1708                }
1709                for part in &open.parts {
1710                    push_part_line(&mut s, part)?;
1711                }
1712            } else if let Some(pdt) = &open.program_date_time {
1713                s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
1714            }
1715            for tag in &open.pre_tags {
1716                s.push_str(tag);
1717                s.push('\n');
1718            }
1719        }
1720
1721        // LL-HLS preload hint for the next not-yet-available part or map
1722        // (RFC 8216bis §4.4.5.3) — after the segment list, before ENDLIST.
1723        if let Some(ll) = &self.low_latency
1724            && let Some(uri) = &ll.preload_hint_part
1725        {
1726            s.push_str(&format!(
1727                "#EXT-X-PRELOAD-HINT:TYPE={},URI=\"{uri}\"",
1728                ll.preload_hint_type.name(),
1729            ));
1730            if let Some(start) = ll.preload_hint_byte_range_start {
1731                s.push_str(&format!(",BYTERANGE-START={start}"));
1732            }
1733            if let Some(len) = ll.preload_hint_byte_range_length {
1734                s.push_str(&format!(",BYTERANGE-LENGTH={len}"));
1735            }
1736            render_attribute_list(&mut s, &ll.ph_extra_attrs);
1737            s.push('\n');
1738        }
1739
1740        // #EXT-X-RENDITION-REPORT entries (RFC 8216bis §4.4.5.4).
1741        for rr in &self.rendition_reports {
1742            s.push_str(&format!(
1743                "#EXT-X-RENDITION-REPORT:URI=\"{}\",LAST-MSN={}",
1744                rr.uri, rr.last_msn
1745            ));
1746            if let Some(lp) = rr.last_part {
1747                s.push_str(&format!(",LAST-PART={lp}"));
1748            }
1749            render_attribute_list(&mut s, &rr.extra_attrs);
1750            s.push('\n');
1751        }
1752
1753        if self.endlist {
1754            s.push_str("#EXT-X-ENDLIST\n");
1755        }
1756
1757        Ok(s)
1758    }
1759
1760    /// Reject any field that would be rendered inside an RFC 8216 §4.2
1761    /// quoted-string or as a URI but contains `"`, CR or LF (audit BH-W7,
1762    /// issue #1111) — a tag/attribute-list injection vector for a playlist
1763    /// assembled from external data. Covers every field [`Self::to_m3u8`]
1764    /// emits; the per-tag renderers re-check the same values, so a caller
1765    /// that bypasses this method still cannot emit a malformed line.
1766    fn validate_for_render(&self) -> Result<()> {
1767        // Opaque verbatim lines: not a quoted-string, but a newline would
1768        // still split the line into two.
1769        validate_tag_body("playlist-level extra tag", &self.extra_tags)?;
1770        for seg in &self.segments {
1771            validate_uri(&seg.uri, "segment URI")?;
1772            if let Some(title) = &seg.title {
1773                validate_quoted_string(title, "EXTINF title")?;
1774            }
1775            validate_tag_body("segment pre-tag", &seg.pre_tags)?;
1776            for part in &seg.parts {
1777                validate_uri(&part.uri, "EXT-X-PART URI")?;
1778            }
1779            if let Some(map) = &seg.map {
1780                validate_uri(&map.uri, "EXT-X-MAP URI")?;
1781            }
1782        }
1783        if let Some(open) = &self.open_segment {
1784            validate_tag_body("open-segment pre-tag", &open.pre_tags)?;
1785            for part in &open.parts {
1786                validate_uri(&part.uri, "EXT-X-PART URI")?;
1787            }
1788            if let Some(map) = &open.map {
1789                validate_uri(&map.uri, "EXT-X-MAP URI")?;
1790            }
1791        }
1792        if let Some(ll) = &self.low_latency
1793            && let Some(uri) = &ll.preload_hint_part
1794        {
1795            validate_uri(uri, "EXT-X-PRELOAD-HINT URI")?;
1796        }
1797        for report in &self.rendition_reports {
1798            validate_uri(&report.uri, "EXT-X-RENDITION-REPORT URI")?;
1799        }
1800        if let Some(skip) = &self.skip {
1801            // RFC 8216bis §4.4.5.2: the ids are one quoted-string, tab (0x9)
1802            // delimited — so a tab inside an id would split it into two ids.
1803            for id in &skip.recently_removed_daterange_ids {
1804                validate_quoted_string(id, "EXT-X-SKIP RECENTLY-REMOVED-DATERANGES id")?;
1805                if id.contains('\t') {
1806                    return Err(Error::InvalidQuotedString {
1807                        what: "EXT-X-SKIP RECENTLY-REMOVED-DATERANGES id".to_string(),
1808                        bad_char: '\t',
1809                        value: id.clone(),
1810                    });
1811                }
1812            }
1813        }
1814        Ok(())
1815    }
1816
1817    /// Parse an RFC 8216bis `#EXTM3U` Media Playlist — the symmetric inverse
1818    /// of [`Self::to_m3u8`]. See the module docs for the recognized-tag list
1819    /// and the documented modeling gaps.
1820    ///
1821    /// Unrecognized `#EXT-...` tags are preserved verbatim into
1822    /// [`Self::extra_tags`] rather than erroring (forward-compat); a
1823    /// non-`#EXT` comment line (RFC 8216 §4.1) is silently ignored. A known
1824    /// tag with a missing required attribute or an unparsable value returns
1825    /// [`crate::Error::HlsParse`].
1826    pub fn parse(input: &str) -> Result<Self> {
1827        // `0` (not `1`): distinguishes "no #EXT-X-VERSION line was present
1828        // on the wire at all" from "the wire explicitly said version 1",
1829        // so a fully-untagged input round-trips back to no tag rather than
1830        // gaining one (see `effective_version`/issue #871).
1831        let mut version: u8 = 0;
1832        let mut target_duration: Option<u32> = None;
1833        let mut media_sequence: u64 = 0;
1834        let mut discontinuity_sequence: u64 = 0;
1835        let mut saw_discontinuity_sequence = false;
1836        let mut iframes_only = false;
1837        let mut endlist = false;
1838        let mut pending_title: Option<String> = None;
1839        let mut extra_tags: Vec<String> = Vec::new();
1840        let mut segments: Vec<MediaSegment> = Vec::new();
1841        let mut rendition_reports: Vec<RenditionReport> = Vec::new();
1842        let mut skip: Option<SkipInfo> = None;
1843        let mut saw_extm3u = false;
1844        // §4.4.2/§4.4.3.5 accumulators (issue #872).
1845        let mut independent_segments = false;
1846        let mut start: Option<StartPoint> = None;
1847        let mut defines: Vec<Define> = Vec::new();
1848        let mut playlist_type: Option<PlaylistType> = None;
1849
1850        // Low-Latency HLS accumulators.
1851        let mut part_target: Option<DecimalSeconds> = None;
1852        let mut part_hold_back: Option<DecimalSeconds> = None;
1853        let mut can_skip_until: Option<DecimalSeconds> = None;
1854        let mut can_skip_dateranges = false;
1855        let mut hold_back: Option<DecimalSeconds> = None;
1856        // RFC 8216bis §4.4.3.8: absent CAN-BLOCK-RELOAD (or an absent
1857        // #EXT-X-SERVER-CONTROL tag entirely) means the server does NOT
1858        // support Blocking Playlist Reload — default false, not the
1859        // `LowLatencyConfig::default()` convenience value of true.
1860        let mut can_block_reload = false;
1861        let mut preload_hint_part: Option<String> = None;
1862        let mut preload_hint_type = PreloadHintType::Part;
1863        let mut preload_hint_byte_range_start: Option<u64> = None;
1864        let mut preload_hint_byte_range_length: Option<u64> = None;
1865        let mut saw_ll_tag = false;
1866        let mut sc_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
1867        let mut pi_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
1868        let mut ph_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
1869
1870        // Per-segment pending state, reset each time a bare URI line closes
1871        // a segment.
1872        let mut current_map: Option<MapTag> = None;
1873        let mut pending_discontinuous = false;
1874        let mut pending_byte_range: Option<ByteRange> = None;
1875        let mut pending_parts: Vec<PartSpec> = Vec::new();
1876        let mut pending_duration: Option<DecimalSeconds> = None;
1877        // §4.4.4.7/§4.4.4.8 per-segment state (issue #872): GAP applies only
1878        // to the next segment; BITRATE carries forward like MAP.
1879        let mut pending_gap = false;
1880        let mut current_bitrate: Option<u64> = None;
1881        // Segment-scoped unmodeled tags accumulated for the next segment
1882        // (audit BH-W5, issue #1111).
1883        let mut pending_pre_tags: Vec<String> = Vec::new();
1884        // Typed `#EXT-X-PROGRAM-DATE-TIME` for the next segment (issue #1423).
1885        let mut pending_program_date_time: Option<ProgramDateTime> = None;
1886
1887        for (idx, raw_line) in input.lines().enumerate() {
1888            let line_no = idx + 1;
1889            let mut line = raw_line.trim_end_matches('\r');
1890            if line_no == 1 {
1891                line = line.strip_prefix('\u{feff}').unwrap_or(line);
1892            }
1893            let line = line.trim();
1894            if line.is_empty() {
1895                continue;
1896            }
1897
1898            if line == "#EXTM3U" {
1899                saw_extm3u = true;
1900            } else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
1901                version = parse_decimal_int(rest, line_no, line, "EXT-X-VERSION")?;
1902            } else if let Some(rest) = line.strip_prefix("#EXT-X-TARGETDURATION:") {
1903                target_duration = Some(parse_decimal_int(
1904                    rest,
1905                    line_no,
1906                    line,
1907                    "EXT-X-TARGETDURATION",
1908                )?);
1909            } else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
1910                media_sequence = parse_decimal_int(rest, line_no, line, "EXT-X-MEDIA-SEQUENCE")?;
1911            } else if let Some(rest) = line.strip_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:") {
1912                discontinuity_sequence =
1913                    parse_decimal_int(rest, line_no, line, "EXT-X-DISCONTINUITY-SEQUENCE")?;
1914                saw_discontinuity_sequence = true;
1915            } else if line == "#EXT-X-I-FRAMES-ONLY" {
1916                iframes_only = true;
1917            } else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
1918                independent_segments = true;
1919            } else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
1920                start = Some(parse_start(rest, line_no, line)?);
1921            } else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
1922                defines.push(parse_define(rest, line_no, line)?);
1923            } else if let Some(rest) = line.strip_prefix("#EXT-X-PLAYLIST-TYPE:") {
1924                playlist_type = Some(match rest.trim() {
1925                    "EVENT" => PlaylistType::Event,
1926                    "VOD" => PlaylistType::Vod,
1927                    other => {
1928                        return Err(Error::HlsParse {
1929                            line_no,
1930                            line: line.to_string(),
1931                            reason: format!(
1932                                "EXT-X-PLAYLIST-TYPE value {other:?} is neither EVENT nor VOD"
1933                            ),
1934                        });
1935                    }
1936                });
1937            } else if line == "#EXT-X-ENDLIST" {
1938                endlist = true;
1939            } else if line == "#EXT-X-DISCONTINUITY" {
1940                pending_discontinuous = true;
1941            } else if line == "#EXT-X-GAP" {
1942                pending_gap = true;
1943            } else if let Some(rest) = line.strip_prefix("#EXT-X-BITRATE:") {
1944                current_bitrate = Some(parse_decimal_int(rest, line_no, line, "EXT-X-BITRATE")?);
1945            } else if let Some(rest) = line.strip_prefix("#EXT-X-BYTERANGE:") {
1946                pending_byte_range = Some(ByteRange::parse(rest, line_no, line)?);
1947            } else if let Some(rest) = line.strip_prefix("#EXT-X-MAP:") {
1948                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
1949                let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-MAP")?;
1950                let byte_range = match attrs.get("BYTERANGE") {
1951                    Some(v) => Some(ByteRange::parse(v, line_no, line)?),
1952                    None => None,
1953                };
1954                let extra_attrs = filter_extra_attrs(&attrs, &["URI", "BYTERANGE"], &quoted)?;
1955                current_map = Some(MapTag {
1956                    uri,
1957                    byte_range,
1958                    extra_attrs,
1959                });
1960            } else if let Some(rest) = line.strip_prefix("#EXTINF:") {
1961                let (dur_str, title) = match rest.split_once(',') {
1962                    Some((dur, title)) => (dur, title),
1963                    None => (rest, ""),
1964                };
1965                pending_duration = Some(parse_decimal_seconds(
1966                    dur_str,
1967                    line_no,
1968                    line,
1969                    "EXTINF duration",
1970                )?);
1971                // Audit BH-W11 (issue #1111): the title after the comma
1972                // (RFC 8216bis §4.4.4.1) is part of the tag, so keep it —
1973                // it used to be split off and thrown away.
1974                pending_title = (!title.is_empty()).then(|| title.to_string());
1975            } else if let Some(rest) = line.strip_prefix("#EXT-X-PART-INF:") {
1976                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
1977                if let Some(v) = attrs.get("PART-TARGET") {
1978                    part_target = Some(parse_decimal_seconds(v, line_no, line, "PART-TARGET")?);
1979                }
1980                pi_extra_attrs.extend(filter_extra_attrs(&attrs, &["PART-TARGET"], &quoted)?);
1981                saw_ll_tag = true;
1982            } else if let Some(rest) = line.strip_prefix("#EXT-X-SERVER-CONTROL:") {
1983                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
1984                if let Some(v) = attrs.get("PART-HOLD-BACK") {
1985                    part_hold_back =
1986                        Some(parse_decimal_seconds(v, line_no, line, "PART-HOLD-BACK")?);
1987                }
1988                if let Some(v) = attrs.get("CAN-SKIP-UNTIL") {
1989                    can_skip_until =
1990                        Some(parse_decimal_seconds(v, line_no, line, "CAN-SKIP-UNTIL")?);
1991                }
1992                can_skip_dateranges =
1993                    attrs.get("CAN-SKIP-DATERANGES").map(String::as_str) == Some("YES");
1994                can_block_reload = attrs.get("CAN-BLOCK-RELOAD").map(String::as_str) == Some("YES");
1995                if let Some(v) = attrs.get("HOLD-BACK") {
1996                    hold_back = Some(parse_decimal_seconds(v, line_no, line, "HOLD-BACK")?);
1997                }
1998                sc_extra_attrs.extend(filter_extra_attrs(
1999                    &attrs,
2000                    &[
2001                        "PART-HOLD-BACK",
2002                        "CAN-SKIP-UNTIL",
2003                        "CAN-BLOCK-RELOAD",
2004                        "HOLD-BACK",
2005                        "CAN-SKIP-DATERANGES",
2006                    ],
2007                    &quoted,
2008                )?);
2009                saw_ll_tag = true;
2010            } else if let Some(rest) = line.strip_prefix("#EXT-X-PART:") {
2011                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2012                let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-PART")?;
2013                let duration_str = attrs.get("DURATION").ok_or_else(|| Error::HlsParse {
2014                    line_no,
2015                    line: line.to_string(),
2016                    reason: "EXT-X-PART missing required DURATION attribute".to_string(),
2017                })?;
2018                let duration =
2019                    parse_decimal_seconds(duration_str, line_no, line, "EXT-X-PART DURATION")?;
2020                let independent = attrs.get("INDEPENDENT").map(String::as_str) == Some("YES");
2021                let gap = attrs.get("GAP").map(String::as_str) == Some("YES");
2022                let byte_range = match attrs.get("BYTERANGE") {
2023                    Some(v) => Some(ByteRange::parse(v, line_no, line)?),
2024                    None => None,
2025                };
2026                let extra_attrs = filter_extra_attrs(
2027                    &attrs,
2028                    &["URI", "DURATION", "INDEPENDENT", "GAP", "BYTERANGE"],
2029                    &quoted,
2030                )?;
2031                pending_parts.push(PartSpec {
2032                    uri,
2033                    duration,
2034                    independent,
2035                    byte_range,
2036                    gap,
2037                    extra_attrs,
2038                });
2039                saw_ll_tag = true;
2040            } else if let Some(rest) = line.strip_prefix("#EXT-X-PRELOAD-HINT:") {
2041                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2042                preload_hint_type = match attrs.get("TYPE").map(String::as_str) {
2043                    Some("MAP") => PreloadHintType::Map,
2044                    _ => PreloadHintType::Part,
2045                };
2046                preload_hint_part = Some(require_attr(
2047                    &attrs,
2048                    "URI",
2049                    line_no,
2050                    line,
2051                    "EXT-X-PRELOAD-HINT",
2052                )?);
2053                if let Some(v) = attrs.get("BYTERANGE-START") {
2054                    preload_hint_byte_range_start =
2055                        Some(parse_decimal_int(v, line_no, line, "BYTERANGE-START")?);
2056                }
2057                if let Some(v) = attrs.get("BYTERANGE-LENGTH") {
2058                    preload_hint_byte_range_length =
2059                        Some(parse_decimal_int(v, line_no, line, "BYTERANGE-LENGTH")?);
2060                }
2061                ph_extra_attrs.extend(filter_extra_attrs(
2062                    &attrs,
2063                    &["TYPE", "URI", "BYTERANGE-START", "BYTERANGE-LENGTH"],
2064                    &quoted,
2065                )?);
2066                saw_ll_tag = true;
2067            } else if let Some(rest) = line.strip_prefix("#EXT-X-RENDITION-REPORT:") {
2068                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2069                let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-RENDITION-REPORT")?;
2070                // Audit BH-W2 (issue #1111): LAST-MSN is REQUIRED
2071                // (RFC 8216bis §4.4.5.3); defaulting it to 0 fabricated a
2072                // report that sends clients to a long-gone segment.
2073                let last_msn_str =
2074                    require_attr(&attrs, "LAST-MSN", line_no, line, "EXT-X-RENDITION-REPORT")?;
2075                let last_msn = parse_decimal_int(&last_msn_str, line_no, line, "LAST-MSN")?;
2076                let last_part = match attrs.get("LAST-PART") {
2077                    Some(v) => Some(parse_decimal_int(v, line_no, line, "LAST-PART")?),
2078                    None => None,
2079                };
2080                let extra_attrs =
2081                    filter_extra_attrs(&attrs, &["URI", "LAST-MSN", "LAST-PART"], &quoted)?;
2082                rendition_reports.push(RenditionReport {
2083                    uri,
2084                    last_msn,
2085                    last_part,
2086                    extra_attrs,
2087                });
2088            } else if let Some(rest) = line.strip_prefix("#EXT-X-SKIP:") {
2089                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2090                let skipped_segments_str =
2091                    require_attr(&attrs, "SKIPPED-SEGMENTS", line_no, line, "EXT-X-SKIP")?;
2092                let skipped_segments =
2093                    parse_decimal_int(&skipped_segments_str, line_no, line, "SKIPPED-SEGMENTS")?;
2094                let recently_removed_daterange_ids = attrs
2095                    .get("RECENTLY-REMOVED-DATERANGES")
2096                    .map(|v| {
2097                        v.split('\t')
2098                            .filter(|s| !s.is_empty())
2099                            .map(ToString::to_string)
2100                            .collect()
2101                    })
2102                    .unwrap_or_default();
2103                let extra_attrs = filter_extra_attrs(
2104                    &attrs,
2105                    &["SKIPPED-SEGMENTS", "RECENTLY-REMOVED-DATERANGES"],
2106                    &quoted,
2107                )?;
2108                skip = Some(SkipInfo {
2109                    skipped_segments,
2110                    recently_removed_daterange_ids,
2111                    extra_attrs,
2112                });
2113            } else if let Some(rest) = line.strip_prefix("#EXT-X-PROGRAM-DATE-TIME:") {
2114                let pdt =
2115                    ProgramDateTime::parse(rest.trim()).map_err(|_| crate::Error::HlsParse {
2116                        line_no,
2117                        line: line.to_string(),
2118                        reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid ISO 8601 date/time"
2119                            .to_string(),
2120                    })?;
2121                pending_program_date_time = Some(pdt);
2122            } else if let Some(rest) = line.strip_prefix("#EXT") {
2123                let _ = rest;
2124                // A well-formed but unrecognized tag: preserve verbatim
2125                // (forward-compat) rather than error or drop. A
2126                // segment-defining tag (`EXT-X-KEY`/`EXT-X-PROGRAM-DATE-TIME`/
2127                // `EXT-X-DATERANGE`/`EXT-X-CUE-*`) is attached to the
2128                // *following* segment so it re-renders in place; everything
2129                // else is playlist-level and goes in the header block
2130                // (audit BH-W5, issue #1111).
2131                if is_segment_defining_tag(line) {
2132                    pending_pre_tags.push(line.to_string());
2133                } else {
2134                    extra_tags.push(line.to_string());
2135                }
2136            } else if line.starts_with('#') {
2137                // RFC 8216 §4.1: a non-"#EXT" '#' line is a comment — ignore.
2138            } else {
2139                // A bare (non-'#') line is always a Media Segment URI; parts
2140                // have no URI line of their own (their URI is an attribute).
2141                let duration = pending_duration.take().ok_or_else(|| Error::HlsParse {
2142                    line_no,
2143                    line: line.to_string(),
2144                    reason: "media segment URI with no preceding #EXTINF".to_string(),
2145                })?;
2146                segments.push(MediaSegment {
2147                    uri: line.to_string(),
2148                    duration,
2149                    discontinuous: core::mem::take(&mut pending_discontinuous),
2150                    parts: core::mem::take(&mut pending_parts),
2151                    byte_range: pending_byte_range.take(),
2152                    map: current_map.clone(),
2153                    gap: core::mem::take(&mut pending_gap),
2154                    bitrate: current_bitrate,
2155                    title: pending_title.take(),
2156                    pre_tags: core::mem::take(&mut pending_pre_tags),
2157                    program_date_time: pending_program_date_time.take(),
2158                });
2159            }
2160        }
2161
2162        if !saw_extm3u {
2163            return Err(Error::HlsParse {
2164                line_no: 1,
2165                line: String::new(),
2166                reason: "missing #EXTM3U header".to_string(),
2167            });
2168        }
2169        let target_duration = target_duration.ok_or_else(|| Error::HlsParse {
2170            line_no: 0,
2171            line: String::new(),
2172            reason: "missing required #EXT-X-TARGETDURATION".to_string(),
2173        })?;
2174
2175        // Any parts accumulated but never closed by a following #EXTINF/URI
2176        // are the in-progress (open) segment at the live edge
2177        // (RFC 8216bis §4.4.4.9).
2178        let open_segment = if pending_parts.is_empty()
2179            && pending_pre_tags.is_empty()
2180            && pending_program_date_time.is_none()
2181        {
2182            None
2183        } else {
2184            let open = OpenSegment::new(core::mem::take(&mut pending_parts));
2185            let open = match &current_map {
2186                Some(map) => open.with_map(map.clone()),
2187                None => open,
2188            };
2189            Some(OpenSegment {
2190                pre_tags: core::mem::take(&mut pending_pre_tags),
2191                program_date_time: pending_program_date_time.take(),
2192                ..open
2193            })
2194        };
2195
2196        if let (Some(start), Some(len)) = (
2197            preload_hint_byte_range_start,
2198            preload_hint_byte_range_length,
2199        ) && start.checked_add(len).is_none()
2200        {
2201            return Err(Error::HlsParse {
2202                line_no: 0,
2203                line: String::new(),
2204                reason: format!(
2205                    "PRELOAD-HINT BYTERANGE-START ({start}) + BYTERANGE-LENGTH ({len}) overflows u64"
2206                ),
2207            });
2208        }
2209
2210        let low_latency = if saw_ll_tag {
2211            Some(LowLatencyConfig {
2212                part_target,
2213                part_hold_back,
2214                preload_hint_part,
2215                preload_hint_type,
2216                preload_hint_byte_range_start,
2217                preload_hint_byte_range_length,
2218                can_skip_until,
2219                can_block_reload,
2220                sc_extra_attrs,
2221                pi_extra_attrs,
2222                ph_extra_attrs,
2223                hold_back,
2224                can_skip_dateranges,
2225            })
2226        } else {
2227            None
2228        };
2229
2230        Ok(MediaPlaylist {
2231            version,
2232            target_duration,
2233            media_sequence,
2234            discontinuity_sequence,
2235            always_emit_discontinuity_sequence: saw_discontinuity_sequence,
2236            segments,
2237            open_segment,
2238            endlist,
2239            extra_tags,
2240            low_latency,
2241            iframes_only,
2242            rendition_reports,
2243            skip,
2244            independent_segments,
2245            start,
2246            defines,
2247            playlist_type,
2248        })
2249    }
2250}
2251
2252/// Render one `#EXT-X-PART:DURATION=<sec>,URI="<uri>"[,BYTERANGE="<n>[@<o>]"]
2253/// [,INDEPENDENT=YES][,GAP=YES]` line (RFC 8216bis §4.4.4.9) into `s`, shared
2254/// by both a closed segment's parts and an open (in-progress) segment's parts
2255/// so the two can never drift in format.
2256fn push_part_line(s: &mut String, part: &PartSpec) -> Result<()> {
2257    validate_uri(&part.uri, "EXT-X-PART URI")?;
2258    s.push_str(&format!(
2259        "#EXT-X-PART:DURATION={},URI=\"{}\"",
2260        format_secs(part.duration),
2261        part.uri,
2262    ));
2263    if let Some(br) = &part.byte_range {
2264        s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
2265    }
2266    if part.independent {
2267        s.push_str(",INDEPENDENT=YES");
2268    }
2269    if part.gap {
2270        s.push_str(",GAP=YES");
2271    }
2272    render_attribute_list(s, &part.extra_attrs);
2273    s.push('\n');
2274    Ok(())
2275}
2276
2277/// Render one `#EXT-X-MAP:URI="<uri>"[,BYTERANGE="<n>@<o>"]` line
2278/// (RFC 8216bis §4.4.4.5).
2279fn push_map_line(s: &mut String, map: &MapTag) -> Result<()> {
2280    validate_uri(&map.uri, "EXT-X-MAP URI")?;
2281    s.push_str(&format!("#EXT-X-MAP:URI=\"{}\"", map.uri));
2282    if let Some(br) = &map.byte_range {
2283        s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
2284    }
2285    render_attribute_list(s, &map.extra_attrs);
2286    s.push('\n');
2287    Ok(())
2288}
2289
2290/// Render one `#EXT-X-DEFINE:...` line (RFC 8216bis §4.4.2.3, issue #872).
2291fn push_define_line(s: &mut String, def: &Define) -> Result<()> {
2292    match def {
2293        Define::Name {
2294            name,
2295            value,
2296            extra_attrs,
2297        } => {
2298            validate_quoted_string(name, "EXT-X-DEFINE NAME")?;
2299            validate_quoted_string(value, "EXT-X-DEFINE VALUE")?;
2300            s.push_str(&format!("#EXT-X-DEFINE:NAME=\"{name}\",VALUE=\"{value}\""));
2301            render_attribute_list(s, extra_attrs);
2302            s.push('\n');
2303        }
2304        Define::Import { name, extra_attrs } => {
2305            validate_quoted_string(name, "EXT-X-DEFINE IMPORT")?;
2306            s.push_str(&format!("#EXT-X-DEFINE:IMPORT=\"{name}\""));
2307            render_attribute_list(s, extra_attrs);
2308            s.push('\n');
2309        }
2310        Define::QueryParam { name, extra_attrs } => {
2311            validate_quoted_string(name, "EXT-X-DEFINE QUERYPARAM")?;
2312            s.push_str(&format!("#EXT-X-DEFINE:QUERYPARAM=\"{name}\""));
2313            render_attribute_list(s, extra_attrs);
2314            s.push('\n');
2315        }
2316    }
2317    Ok(())
2318}
2319
2320/// Parse an `#EXT-X-DEFINE:<attribute-list>` value (RFC 8216bis §4.4.2.3):
2321/// exactly one of `NAME` (+ required `VALUE`), `IMPORT`, `QUERYPARAM`.
2322fn parse_define(rest: &str, line_no: usize, line: &str) -> Result<Define> {
2323    let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2324    let present = [
2325        attrs.contains_key("NAME"),
2326        attrs.contains_key("IMPORT"),
2327        attrs.contains_key("QUERYPARAM"),
2328    ]
2329    .iter()
2330    .filter(|&&b| b)
2331    .count();
2332    if present != 1 {
2333        return Err(Error::HlsParse {
2334            line_no,
2335            line: line.to_string(),
2336            reason: "EXT-X-DEFINE must contain exactly one of NAME, IMPORT, QUERYPARAM".to_string(),
2337        });
2338    }
2339    if let Some(name) = attrs.get("NAME") {
2340        let value = require_attr(&attrs, "VALUE", line_no, line, "EXT-X-DEFINE")?;
2341        let extra_attrs = filter_extra_attrs(&attrs, &["NAME", "VALUE"], &quoted)?;
2342        Ok(Define::Name {
2343            name: name.clone(),
2344            value,
2345            extra_attrs,
2346        })
2347    } else if let Some(name) = attrs.get("IMPORT") {
2348        let extra_attrs = filter_extra_attrs(&attrs, &["IMPORT"], &quoted)?;
2349        Ok(Define::Import {
2350            name: name.clone(),
2351            extra_attrs,
2352        })
2353    } else {
2354        let name = attrs
2355            .get("QUERYPARAM")
2356            .expect("exactly one of the three checked above")
2357            .clone();
2358        let extra_attrs = filter_extra_attrs(&attrs, &["QUERYPARAM"], &quoted)?;
2359        Ok(Define::QueryParam { name, extra_attrs })
2360    }
2361}
2362
2363/// The wire representation of one attribute-list value (RFC 8216 §4.2):
2364/// whether it was written as a **quoted-string** or a **bare** token
2365/// (enumerated-string / decimal-integer / decimal-floating-point).
2366///
2367/// `extra_attrs` (every attribute this crate does not model as a typed
2368/// struct field) stores one of these per entry instead of a plain `String`,
2369/// recorded from the actual on-wire token at parse time, so quoting
2370/// round-trips losslessly — including for an attribute this crate has never
2371/// heard of (issue #1045 / audit BH-C1). A plain `String` alone cannot
2372/// represent this: `parse_attribute_list` must strip the surrounding `"` either
2373/// way to recover a quoted value's content, so by the time the value
2374/// reaches `extra_attrs` the two forms are indistinguishable unless
2375/// recorded explicitly.
2376///
2377/// Opaque by construction (issue #1045 T12, coordinator review): a value
2378/// that cannot be represented in its requested kind (a quoted-string
2379/// containing `"`/CR/LF, or a bare value containing a character that would
2380/// require quoting) is rejected by [`Self::quoted`]/[`Self::bare`] rather
2381/// than silently mangled (e.g. percent-encoded) or emitted raw — cf. #1129
2382/// ("never silently wrap/mangle an over-range value"). This keeps
2383/// rendering (`to_m3u8`) infallible without ever letting an invalid value
2384/// exist in the first place; parsing constructs through the same checks
2385/// (a value read off the wire can never fail them — see the module docs on
2386/// `parse_attribute_list`/`filter_extra_attrs`).
2387#[derive(Debug, Clone, PartialEq, Eq)]
2388pub struct AttrValue {
2389    value: String,
2390    quoted: bool,
2391}
2392
2393impl AttrValue {
2394    /// Build a quoted-string attribute value (RFC 8216 §4.2). Rejects a
2395    /// value containing `"`, CR or LF — a quoted-string must never contain
2396    /// any of them.
2397    pub fn quoted(value: impl Into<String>) -> Result<Self> {
2398        let value = value.into();
2399        if value.contains('"') || value.contains('\r') || value.contains('\n') {
2400            return Err(Error::InvalidAttrValue {
2401                value,
2402                kind: "a quoted-string",
2403            });
2404        }
2405        Ok(Self {
2406            value,
2407            quoted: true,
2408        })
2409    }
2410
2411    /// Build a bare enumerated-string/decimal attribute value. Rejects a
2412    /// value containing `,`, `"`, CR, LF or whitespace, any of which would
2413    /// require quoting.
2414    pub fn bare(value: impl Into<String>) -> Result<Self> {
2415        let value = value.into();
2416        if value.contains(',')
2417            || value.contains('"')
2418            || value.contains('\r')
2419            || value.contains('\n')
2420            || value.contains(char::is_whitespace)
2421        {
2422            return Err(Error::InvalidAttrValue {
2423                value,
2424                kind: "a bare value",
2425            });
2426        }
2427        Ok(Self {
2428            value,
2429            quoted: false,
2430        })
2431    }
2432
2433    /// The value's content, with wire quoting (if any) already stripped.
2434    #[must_use]
2435    pub fn as_str(&self) -> &str {
2436        &self.value
2437    }
2438
2439    /// Whether this value renders quoted (`"<content>"`) or bare.
2440    #[must_use]
2441    pub fn is_quoted(&self) -> bool {
2442        self.quoted
2443    }
2444
2445    /// Build an [`AttrValue`] for a **programmatically constructed**
2446    /// attribute (e.g. assembling a fresh [`Variant`] rather than parsing
2447    /// one), choosing [`Self::quoted`] or [`Self::bare`] from a static
2448    /// table of known RFC 8216bis quoted-string attribute names
2449    /// (§4.4.6.2 / §4.4.6.2.1 — spec text at
2450    /// `specs/ietf_draft_pantos_hls_rfc8216bis.txt` lines 2637-2980) for a
2451    /// recognized name, falling back to sniffing the value's content for
2452    /// one this crate doesn't recognize (a non-`,`/`"`/whitespace/
2453    /// non-empty value is left unquoted). Prefer calling
2454    /// [`Self::quoted`]/[`Self::bare`] directly when the correct kind is
2455    /// already known — this is a convenience for the common case, not the
2456    /// source of truth (parsing always records the true wire form). Fails
2457    /// exactly when the chosen constructor would (the value contains a
2458    /// character forbidden for that kind).
2459    pub fn for_attr(name: &str, value: impl Into<String>) -> Result<Self> {
2460        let value = value.into();
2461        match known_attr_value_kind(name) {
2462            Some(AttrValueKind::Quoted) => Self::quoted(value),
2463            Some(AttrValueKind::QuotedUnless(bare)) => {
2464                if value == bare {
2465                    Self::bare(value)
2466                } else {
2467                    Self::quoted(value)
2468                }
2469            }
2470            None => {
2471                if value.is_empty()
2472                    || value.contains(',')
2473                    || value.contains('"')
2474                    || value.contains(char::is_whitespace)
2475                {
2476                    Self::quoted(value)
2477                } else {
2478                    Self::bare(value)
2479                }
2480            }
2481        }
2482    }
2483}
2484
2485/// `serde` support for [`AttrValue`] (issue #1140: `timed-metadata`'s
2486/// `DateRange::extra_attrs` needs to derive `Serialize`/`Deserialize`
2487/// through it). A blind field-derive would let an untrusted JSON payload
2488/// deserialize an `AttrValue` whose content was never checked by
2489/// [`AttrValue::quoted`]/[`AttrValue::bare`] — exactly the injection this
2490/// type exists to prevent — so `Deserialize` is hand-written to go through
2491/// those constructors instead.
2492#[cfg(feature = "serde")]
2493impl serde::Serialize for AttrValue {
2494    fn serialize<S: serde::Serializer>(
2495        &self,
2496        serializer: S,
2497    ) -> core::result::Result<S::Ok, S::Error> {
2498        use serde::ser::SerializeStruct;
2499        let mut s = serializer.serialize_struct("AttrValue", 2)?;
2500        s.serialize_field("value", &self.value)?;
2501        s.serialize_field("quoted", &self.quoted)?;
2502        s.end()
2503    }
2504}
2505
2506#[cfg(feature = "serde")]
2507impl<'de> serde::Deserialize<'de> for AttrValue {
2508    fn deserialize<D: serde::Deserializer<'de>>(
2509        deserializer: D,
2510    ) -> core::result::Result<Self, D::Error> {
2511        #[derive(serde::Deserialize)]
2512        struct Raw {
2513            value: String,
2514            quoted: bool,
2515        }
2516        let raw = Raw::deserialize(deserializer)?;
2517        if raw.quoted {
2518            AttrValue::quoted(raw.value)
2519        } else {
2520            AttrValue::bare(raw.value)
2521        }
2522        .map_err(serde::de::Error::custom)
2523    }
2524}
2525
2526/// The RFC 8216bis §4.4.6.2 / §4.4.6.2.1 attribute-value *kind* for an
2527/// attribute name this crate does not model as a typed struct field —
2528/// used only by [`AttrValue::for_attr`]'s programmatic-construction
2529/// convenience (issue #1045 / audit BH-C1); parsing never consults this,
2530/// since it records the true wire form directly.
2531enum AttrValueKind {
2532    /// Always a quoted-string.
2533    Quoted,
2534    /// A quoted-string, *unless* the value is exactly this bare enumerated
2535    /// token (case-sensitive) — e.g. `CLOSED-CAPTIONS=NONE`.
2536    QuotedUnless(&'static str),
2537}
2538
2539/// Known quoted-string attributes not modeled as typed fields on
2540/// [`Variant`]/[`IFrameVariant`] (`#EXT-X-STREAM-INF` / `#EXT-X-I-FRAME-
2541/// STREAM-INF`). Extend this list if a future unmodeled attribute is added
2542/// elsewhere; an attribute absent from it falls back to content-sniffing in
2543/// [`AttrValue::for_attr`].
2544fn known_attr_value_kind(name: &str) -> Option<AttrValueKind> {
2545    match name {
2546        "AUDIO"
2547        | "VIDEO"
2548        | "SUBTITLES"
2549        | "PATHWAY-ID"
2550        | "STABLE-VARIANT-ID"
2551        | "STABLE-RENDITION-ID"
2552        | "SUPPLEMENTAL-CODECS"
2553        | "REQ-VIDEO-LAYOUT"
2554        | "ALLOWED-CPC" => Some(AttrValueKind::Quoted),
2555        "CLOSED-CAPTIONS" => Some(AttrValueKind::QuotedUnless("NONE")),
2556        _ => None,
2557    }
2558}
2559
2560/// Append `,NAME=VALUE` (or `,NAME="VALUE"`) for each attribute in an HLS
2561/// `<attribute-list>` (RFC 8216bis §4.2), in the given order, with no
2562/// leading tag prefix and no trailing newline.
2563///
2564/// This is the single attribute-list renderer for the workspace (issue
2565/// #1140 T12): `ssai-runtime` and `timed-metadata` each used to hand-format
2566/// `,NAME="VALUE"` themselves with no validation of the interpolated
2567/// content — an injection vector when that content comes from outside the
2568/// crate (a third-party ad-decision service, a caller-assembled
2569/// `DATERANGE`). Both now build a `Vec<(String, AttrValue)>` and call this
2570/// function instead.
2571///
2572/// Quoting is exactly what [`AttrValue`] records — no guessing at render
2573/// time, and infallible: an [`AttrValue`] can only ever hold content valid
2574/// for its kind (RFC 8216 §4.2's forbidden `"`/CR/LF in a quoted-string,
2575/// or `,`/`"`/CR/LF/whitespace in a bare value, are rejected at
2576/// construction — [`AttrValue::quoted`]/[`AttrValue::bare`], issue #1045
2577/// T12) — so there is nothing left to validate or sanitize here. A caller
2578/// assembling attributes from external data MUST go through those
2579/// constructors (or [`AttrValue::for_attr`]) first; this function itself
2580/// cannot fail.
2581pub fn render_attribute_list(s: &mut String, attrs: &[(String, AttrValue)]) {
2582    for (name, value) in attrs {
2583        s.push(',');
2584        s.push_str(name);
2585        s.push('=');
2586        if value.is_quoted() {
2587            s.push('"');
2588            s.push_str(value.as_str());
2589            s.push('"');
2590        } else {
2591            s.push_str(value.as_str());
2592        }
2593    }
2594}
2595
2596/// RFC 8216 §4.2: a quoted-string is "a string ... enclosed in double
2597/// quotes"; it MUST NOT contain `"`, CR or LF. Every model field this crate
2598/// renders as a quoted-string attribute value or as a URI goes through the
2599/// constructors below, so a `\n` smuggled into a segment URI (e.g.
2600/// `"seg.m4s\n#EXT-X-ENDLIST"`) can never inject a tag line and an
2601/// embedded `"` can never break out of the attribute list (audit BH-W7,
2602/// issue #1111).
2603const QUOTED_STRING_FORBIDDEN: &[char] = &['"', '\r', '\n'];
2604
2605/// Reject a value that cannot be rendered as an RFC 8216 §4.2
2606/// quoted-string (see [`QUOTED_STRING_FORBIDDEN`]).
2607fn validate_quoted_string(value: &str, what: &str) -> Result<()> {
2608    if let Some(bad) = value.chars().find(|c| QUOTED_STRING_FORBIDDEN.contains(c)) {
2609        return Err(Error::InvalidQuotedString {
2610            what: what.to_string(),
2611            bad_char: bad,
2612            value: value.to_string(),
2613        });
2614    }
2615    Ok(())
2616}
2617
2618/// A tag line this crate re-emits verbatim (an opaque `extra_tags`/
2619/// `pre_tags` entry) is not a quoted-string, but it is one line: a CR or LF
2620/// inside it would split it into two lines and inject a tag (audit BH-W7,
2621/// issue #1111).
2622fn validate_tag_body(what: &str, tags: &[String]) -> Result<()> {
2623    for tag in tags {
2624        if let Some(bad) = tag.chars().find(|c| *c == '\r' || *c == '\n') {
2625            return Err(Error::InvalidQuotedString {
2626                what: what.to_string(),
2627                bad_char: bad,
2628                value: tag.clone(),
2629            });
2630        }
2631    }
2632    Ok(())
2633}
2634
2635/// RFC 8216 §4.1: a URI line is the whole line, so a CR or LF inside one is
2636/// a tag/line injection; §4.2 forbids `"` in a quoted-string URI.
2637fn validate_uri(value: &str, what: &str) -> Result<()> {
2638    if let Some(bad) = value.chars().find(|c| QUOTED_STRING_FORBIDDEN.contains(c)) {
2639        return Err(Error::InvalidUri {
2640            what: what.to_string(),
2641            bad_char: bad,
2642            value: value.to_string(),
2643        });
2644    }
2645    Ok(())
2646}
2647
2648/// Render the `#EXT-X-START:...` line (RFC 8216bis §4.4.2.2, issue #872).
2649fn push_start_line(s: &mut String, start: &StartPoint) {
2650    s.push_str(&format!(
2651        "#EXT-X-START:TIME-OFFSET={}",
2652        format_signed_secs(start.time_offset)
2653    ));
2654    if start.precise {
2655        s.push_str(",PRECISE=YES");
2656    }
2657    render_attribute_list(s, &start.extra_attrs);
2658    s.push('\n');
2659}
2660
2661/// Parse the `#EXT-X-START:<attribute-list>` value.
2662fn parse_start(rest: &str, line_no: usize, line: &str) -> Result<StartPoint> {
2663    let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2664    let time_offset_str = require_attr(&attrs, "TIME-OFFSET", line_no, line, "EXT-X-START")?;
2665    let time_offset = parse_signed_decimal_seconds(&time_offset_str, line_no, line, "TIME-OFFSET")?;
2666    let precise = attrs.get("PRECISE").map(String::as_str) == Some("YES");
2667    let extra_attrs = filter_extra_attrs(&attrs, &["TIME-OFFSET", "PRECISE"], &quoted)?;
2668    Ok(StartPoint {
2669        time_offset,
2670        precise,
2671        extra_attrs,
2672    })
2673}
2674
2675/// Render one `#EXT-X-SESSION-DATA:...` line (RFC 8216bis §4.4.6.4, issue #872).
2676fn push_session_data_line(s: &mut String, sd: &SessionData) -> Result<()> {
2677    validate_quoted_string(&sd.data_id, "EXT-X-SESSION-DATA DATA-ID")?;
2678    s.push_str(&format!("#EXT-X-SESSION-DATA:DATA-ID=\"{}\"", sd.data_id));
2679    match &sd.content {
2680        SessionDataContent::Value(v) => {
2681            validate_quoted_string(v, "EXT-X-SESSION-DATA VALUE")?;
2682            s.push_str(&format!(",VALUE=\"{v}\""));
2683        }
2684        SessionDataContent::Uri { uri, format } => {
2685            validate_uri(uri, "EXT-X-SESSION-DATA URI")?;
2686            s.push_str(&format!(",URI=\"{uri}\""));
2687            if *format == SessionDataFormat::Raw {
2688                s.push_str(",FORMAT=RAW");
2689            }
2690        }
2691    }
2692    if let Some(lang) = &sd.language {
2693        validate_quoted_string(lang, "EXT-X-SESSION-DATA LANGUAGE")?;
2694        s.push_str(&format!(",LANGUAGE=\"{lang}\""));
2695    }
2696    render_attribute_list(s, &sd.extra_attrs);
2697    s.push('\n');
2698    Ok(())
2699}
2700
2701/// Parse an `#EXT-X-SESSION-DATA:<attribute-list>` value.
2702fn parse_session_data(rest: &str, line_no: usize, line: &str) -> Result<SessionData> {
2703    let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2704    let data_id = require_attr(&attrs, "DATA-ID", line_no, line, "EXT-X-SESSION-DATA")?;
2705    let value = attrs.get("VALUE");
2706    let uri = attrs.get("URI");
2707    let content = match (value, uri) {
2708        (Some(v), None) => SessionDataContent::Value(v.clone()),
2709        (None, Some(u)) => {
2710            let format = match attrs.get("FORMAT").map(String::as_str) {
2711                Some("RAW") => SessionDataFormat::Raw,
2712                _ => SessionDataFormat::Json,
2713            };
2714            SessionDataContent::Uri {
2715                uri: u.clone(),
2716                format,
2717            }
2718        }
2719        (Some(_), Some(_)) => {
2720            return Err(Error::HlsParse {
2721                line_no,
2722                line: line.to_string(),
2723                reason: "EXT-X-SESSION-DATA must not contain both VALUE and URI".to_string(),
2724            });
2725        }
2726        (None, None) => {
2727            return Err(Error::HlsParse {
2728                line_no,
2729                line: line.to_string(),
2730                reason: "EXT-X-SESSION-DATA must contain either VALUE or URI".to_string(),
2731            });
2732        }
2733    };
2734    let language = attrs.get("LANGUAGE").cloned();
2735    let known: &[&str] = match &content {
2736        SessionDataContent::Value(_) => &["DATA-ID", "VALUE", "LANGUAGE"],
2737        SessionDataContent::Uri { .. } => &["DATA-ID", "URI", "FORMAT", "LANGUAGE"],
2738    };
2739    let extra_attrs = filter_extra_attrs(&attrs, known, &quoted)?;
2740    Ok(SessionData {
2741        data_id,
2742        content,
2743        language,
2744        extra_attrs,
2745    })
2746}
2747
2748/// Render one `#EXT-X-SESSION-KEY:...` line (RFC 8216bis §4.4.6.5, issue #872).
2749fn push_session_key_line(s: &mut String, sk: &SessionKey) -> Result<()> {
2750    s.push_str(&format!("#EXT-X-SESSION-KEY:METHOD={}", sk.method.name()));
2751    if let Some(uri) = &sk.uri {
2752        validate_uri(uri, "EXT-X-SESSION-KEY URI")?;
2753        s.push_str(&format!(",URI=\"{uri}\""));
2754    }
2755    if let Some(iv) = &sk.iv {
2756        s.push_str(&format!(",IV=0x{}", hex_encode(iv)));
2757    }
2758    if let Some(kf) = &sk.keyformat {
2759        validate_quoted_string(kf, "EXT-X-SESSION-KEY KEYFORMAT")?;
2760        s.push_str(&format!(",KEYFORMAT=\"{kf}\""));
2761    }
2762    if let Some(kfv) = &sk.keyformatversions {
2763        validate_quoted_string(kfv, "EXT-X-SESSION-KEY KEYFORMATVERSIONS")?;
2764        s.push_str(&format!(",KEYFORMATVERSIONS=\"{kfv}\""));
2765    }
2766    render_attribute_list(s, &sk.extra_attrs);
2767    s.push('\n');
2768    Ok(())
2769}
2770
2771/// Parse an `#EXT-X-SESSION-KEY:<attribute-list>` value (same attribute set
2772/// as `#EXT-X-KEY`, RFC 8216bis §4.4.4.4, except METHOD MUST NOT be NONE).
2773fn parse_session_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
2774    parse_key_attributes(rest, line_no, line, "EXT-X-SESSION-KEY", false)
2775}
2776
2777/// Parse an `#EXT-X-KEY:<attribute-list>` value (RFC 8216 §4.3.2.4).
2778fn parse_media_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
2779    parse_key_attributes(rest, line_no, line, "EXT-X-KEY", true)
2780}
2781
2782fn parse_key_attributes(
2783    rest: &str,
2784    line_no: usize,
2785    line: &str,
2786    tag: &str,
2787    allow_none: bool,
2788) -> Result<SessionKey> {
2789    let (attrs, quoted) = parse_attribute_list_strict(rest)?;
2790    let method_str = require_attr(&attrs, "METHOD", line_no, line, tag)?;
2791    let method = match method_str.as_str() {
2792        "NONE" if allow_none => EncryptionMethod::None,
2793        "NONE" => {
2794            return Err(Error::HlsParse {
2795                line_no,
2796                line: line.to_string(),
2797                reason: "EXT-X-SESSION-KEY METHOD MUST NOT be NONE (RFC 8216bis §4.4.6.5)"
2798                    .to_string(),
2799            });
2800        }
2801        "AES-128" => EncryptionMethod::Aes128,
2802        "SAMPLE-AES" => EncryptionMethod::SampleAes,
2803        "SAMPLE-AES-CTR" => EncryptionMethod::SampleAesCtr,
2804        "AES-256-GCM" => EncryptionMethod::Aes256Gcm,
2805        other => {
2806            return Err(Error::HlsParse {
2807                line_no,
2808                line: line.to_string(),
2809                reason: format!("{tag} METHOD value {other:?} is not recognized"),
2810            });
2811        }
2812    };
2813    if method == EncryptionMethod::None && attrs.len() > 1 {
2814        return Err(Error::HlsParse {
2815            line_no,
2816            line: line.to_string(),
2817            reason: format!(
2818                "{tag} METHOD=NONE MUST NOT carry other attributes (RFC 8216bis §4.4.4.4)"
2819            ),
2820        });
2821    }
2822    let uri = attrs.get("URI").cloned();
2823    let iv = match attrs.get("IV") {
2824        Some(v) => Some(parse_iv(v)?),
2825        None => None,
2826    };
2827    let keyformat = attrs.get("KEYFORMAT").cloned();
2828    let keyformatversions = attrs.get("KEYFORMATVERSIONS").cloned();
2829    let extra_attrs = filter_extra_attrs(
2830        &attrs,
2831        &["METHOD", "URI", "IV", "KEYFORMAT", "KEYFORMATVERSIONS"],
2832        &quoted,
2833    )?;
2834    Ok(SessionKey {
2835        method,
2836        uri,
2837        iv,
2838        keyformat,
2839        keyformatversions,
2840        extra_attrs,
2841    })
2842}
2843
2844/// Parse a `0x`-prefixed hexadecimal-sequence attribute value into
2845/// exactly 16 bytes — the 128-bit IV of `#EXT-X-KEY`/`#EXT-X-SESSION-KEY`
2846/// (RFC 8216bis §4.4.4.4). Only the *encoder* half of hex lives in
2847/// `broadcast_common::hex` (see that module's doc comment); each consumer's
2848/// decode error type differs, so the decoder is local to this crate.
2849const IV_HEX_DIGITS: usize = 32;
2850
2851fn parse_iv(s: &str) -> Result<[u8; 16]> {
2852    let hex = s
2853        .strip_prefix("0x")
2854        .or_else(|| s.strip_prefix("0X"))
2855        .filter(|h| h.len() == IV_HEX_DIGITS && h.bytes().all(|b| b.is_ascii_hexdigit()))
2856        .ok_or_else(|| Error::InvalidIv {
2857            value: s.to_string(),
2858        })?;
2859    let mut out = [0u8; 16];
2860    for i in 0..16 {
2861        // Safe: length and hex-digit-ness were just validated above.
2862        out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex digits");
2863    }
2864    Ok(out)
2865}
2866
2867/// Format a possibly-negative seconds value (RFC 8216bis §4.2
2868/// signed-decimal-floating-point — `#EXT-X-START`'s `TIME-OFFSET`), reusing
2869/// [`format_secs`] for the magnitude. Infallible: [`SignedDecimalSeconds`]
2870/// is always finite (issue #1140 T12/BH-W4), so there is no non-finite
2871/// case left to guard against.
2872fn format_signed_secs(v: SignedDecimalSeconds) -> String {
2873    let v = v.get();
2874    if v < 0.0 {
2875        // Safe: `-v` of a finite negative value is finite and non-negative.
2876        format!("-{}", format_secs(DecimalSeconds::new_unchecked(-v)))
2877    } else {
2878        format_secs(DecimalSeconds::new_unchecked(v))
2879    }
2880}
2881
2882/// Format a non-negative seconds value as an HLS decimal-floating-point
2883/// (RFC 8216bis §4.2), **losslessly**.
2884///
2885/// Prefers the historical compact millisecond form (`0.334`, `1.5`, `6` —
2886/// trailing zeros trimmed) whenever that form re-parses to bit-identical
2887/// `v`, so output for the overwhelmingly common ms-granular case is
2888/// unchanged. When it would not (a real LL-HLS playlist's `2.00004`, a real
2889/// segment's `9.9766`), falls back to `core`'s `Display for f64`, which
2890/// emits the shortest decimal string that round-trips exactly — never
2891/// scientific notation, so the result is always valid §4.2 syntax.
2892///
2893/// The millisecond-rounding this replaced silently corrupted any duration
2894/// finer than 1 ms: `2.00004` rendered as `2`. Caught by round-tripping the
2895/// real Apple `fixtures/hls/real/` playlists (issue #872), which no
2896/// hand-made 3-decimal fixture could have surfaced.
2897fn format_secs(v: DecimalSeconds) -> String {
2898    // Infallible (issue #1140 T12/BH-W4): `DecimalSeconds` is always finite
2899    // and non-negative, so there is no NaN/inf/negative case left to guard
2900    // against here — the validation lives entirely at construction.
2901    let v = v.get();
2902    let millis = (v * 1000.0 + 0.5) as u64;
2903    let whole = millis / 1000;
2904    let frac = millis % 1000;
2905    let compact = if frac == 0 {
2906        format!("{whole}")
2907    } else {
2908        let mut f = format!("{frac:03}");
2909        while f.ends_with('0') {
2910            f.pop();
2911        }
2912        format!("{whole}.{f}")
2913    };
2914    if compact.parse::<f64>() == Ok(v) {
2915        return compact;
2916    }
2917    format!("{v}")
2918}
2919
2920/// Format an `#EXTINF` duration losslessly (RFC 8216bis §4.4.4.1).
2921///
2922/// Three tiers, in order:
2923///
2924/// 1. **An exactly-whole number of seconds renders as an integer** (`4.0` ->
2925///    `4`), so the playlist contains no floating-point duration value and is
2926///    honestly compatible with protocol version 1 (§8 row 3 — see
2927///    [`is_fractional_duration`], which is defined in terms of this
2928///    function). Rendering `4.000` here instead declared a float while
2929///    emitting no `EXT-X-VERSION`, locking a v1/v2 client into a value it
2930///    cannot parse.
2931///
2932///    §4.4.4.1 makes this a MUST, not merely an option: `duration` "is a
2933///    decimal-floating-point **or decimal-integer** number", and "if the
2934///    compatibility version number is less than 3, durations MUST be
2935///    integers". Emitting no `EXT-X-VERSION` tag means version 1, so an
2936///    integral render is the only conforming output. (§4.4.4.1 also SHOULDs
2937///    that durations be floating-point for accuracy — but that is authoring
2938///    advice subordinate to the MUST, and it is satisfied the moment a
2939///    caller supplies a genuinely fractional duration, which every real
2940///    keyframe-cutting segmenter does.)
2941/// 2. Otherwise the historical fixed 3-decimal rendering (`9.009` — the form
2942///    every RFC 8216 example and every existing consumer of this crate
2943///    expects) whenever it re-parses to bit-identical `v`.
2944/// 3. Otherwise the shortest exactly-round-tripping decimal, same rule as
2945///    [`format_secs`]. A hardcoded `{:.3}` alone loses real-world precision —
2946///    Apple's BipBop playlists carry `#EXTINF:9.9766`, which would render
2947///    back as `9.977` (issue #882).
2948fn format_extinf(v: DecimalSeconds) -> String {
2949    // Infallible, same reasoning as `format_secs` (issue #1140 T12/BH-W4).
2950    let v = v.get();
2951    if let Some(whole) = whole_seconds(v) {
2952        return format!("{whole}");
2953    }
2954    let three = format!("{v:.3}");
2955    if three.parse::<f64>() == Ok(v) {
2956        return three;
2957    }
2958    format!("{v}")
2959}
2960
2961/// `Some(n)` if `v` is exactly the whole number of seconds `n`, else `None`.
2962///
2963/// Uses an integer cast rather than `f64::fract()`, which is `std`-only and
2964/// unavailable to this `no_std`+`alloc` crate (same constraint that shaped
2965/// [`format_secs`]). The round-trip comparison is what makes it exact: a
2966/// value with any fractional part, however small, fails `(v as u64) as f64
2967/// == v` and is rejected.
2968fn whole_seconds(v: f64) -> Option<u64> {
2969    if !v.is_finite() || !(0.0..WHOLE_SECONDS_CAST_LIMIT).contains(&v) {
2970        return None;
2971    }
2972    let whole = v as u64;
2973    (whole as f64 == v).then_some(whole)
2974}
2975
2976/// Upper bound for the `f64 -> u64` cast in [`whole_seconds`]. Beyond 2^53 an
2977/// `f64` cannot represent consecutive integers anyway, so a duration that
2978/// large is not a meaningful segment length; falling through to the decimal
2979/// path is the safe answer.
2980const WHOLE_SECONDS_CAST_LIMIT: f64 = 9_007_199_254_740_992.0; // 2^53
2981
2982/// Parse an RFC 8216bis §4.2 `decimal-floating-point` value into `f64` with
2983/// the strict lexical grammar (audit BH-W4, issue #1111). `f64::from_str`
2984/// alone accepts `nan`, `inf`, `infinity`, a leading `+`, and exponent
2985/// notation — none of which §4.2 allows — so `#EXTINF:nan,` previously
2986/// parsed `Ok` into a NaN duration that a downstream range check
2987/// (`x < lo || x > hi`) silently treated as in-range. `signed` is true only
2988/// where the spec allows a signed-decimal (TIME-OFFSET, §4.4.2.2).
2989fn parse_decimal_float(
2990    s: &str,
2991    line_no: usize,
2992    line: &str,
2993    what: &str,
2994    signed: bool,
2995) -> Result<f64> {
2996    let t = s.trim();
2997    if !is_valid_decimal_token(t, signed) {
2998        return Err(Error::HlsParse {
2999            line_no,
3000            line: line.to_string(),
3001            reason: format!("{what} value {s:?} is not a valid decimal number"),
3002        });
3003    }
3004    // The grammar above accepts only finite values (non-negative unless
3005    // `signed`).
3006    t.parse::<f64>().map_err(|_| Error::HlsParse {
3007        line_no,
3008        line: line.to_string(),
3009        reason: format!("{what} value {s:?} is not a valid number"),
3010    })
3011}
3012
3013/// Parse an RFC 8216bis §4.2 `decimal-integer` into `T` with the strict
3014/// lexical grammar (audit BH-W4, issue #1111). The integer `from_str` impls
3015/// reject `nan`/`inf`/exponent notation on their own, but they *accept* a
3016/// leading `+` (e.g. `BANDWIDTH=+7`), which §4.2's decimal-integer grammar
3017/// does not allow. `parse_decimal_float` covers the float attributes; every
3018/// integer attribute parse goes through here.
3019fn parse_decimal_int<T: core::str::FromStr>(
3020    s: &str,
3021    line_no: usize,
3022    line: &str,
3023    what: &str,
3024) -> Result<T> {
3025    let t = s.trim();
3026    if !is_valid_decimal_token(t, false) {
3027        return Err(Error::HlsParse {
3028            line_no,
3029            line: line.to_string(),
3030            reason: format!("{what} value {s:?} is not a valid decimal integer"),
3031        });
3032    }
3033    t.parse::<T>().map_err(|_| Error::HlsParse {
3034        line_no,
3035        line: line.to_string(),
3036        reason: format!("{what} value {s:?} is not a valid number"),
3037    })
3038}
3039
3040/// Validate an RFC 8216bis §4.2 `decimal-floating-point` (or
3041/// `decimal-integer`) token's *lexical form* before handing it to
3042/// `f64::from_str`: `-`? (if `signed`) `[0-9]+` (`.` `[0-9]+`)? — nothing
3043/// else. `f64::from_str` alone is far more permissive than the spec (it
3044/// also accepts `nan`, `inf`, `infinity`, a leading `+`, and exponent
3045/// notation, none of which §4.2 allows) and would otherwise let
3046/// `#EXTINF:nan,` (audit BH-W4) parse successfully into a NaN duration
3047/// that a downstream range check (`x < lo || x > hi`) silently treats as
3048/// in-range, or that panics `Duration::from_secs_f64` later. Only
3049/// `TIME-OFFSET` accepts a sign, per §4.4.2.2.
3050fn is_valid_decimal_token(s: &str, signed: bool) -> bool {
3051    let s = s.strip_prefix('-').filter(|_| signed).unwrap_or(s);
3052    let (int_part, frac_part) = match s.split_once('.') {
3053        Some((i, f)) => (i, Some(f)),
3054        None => (s, None),
3055    };
3056    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3057        return false;
3058    }
3059    match frac_part {
3060        Some(f) => !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()),
3061        None => true,
3062    }
3063}
3064
3065/// A validated RFC 8216bis §4.2 `decimal-floating-point` seconds value:
3066/// always finite and non-negative. The field type for every unsigned
3067/// duration attribute this crate models (`EXTINF`, `PART-TARGET`,
3068/// `PART-HOLD-BACK`, `CAN-SKIP-UNTIL`, `HOLD-BACK`, `EXT-X-PART`
3069/// `DURATION`) — issue #1140 T12/BH-W4: previously a directly-constructed
3070/// `f64` field could hold NaN/infinite/negative, and the (necessarily
3071/// infallible) `to_m3u8` renderer clamped it to `"0"` rather than ever
3072/// emitting invalid syntax. With the value validated at construction, no
3073/// invalid value can exist for the renderer to have to defend against.
3074#[derive(Debug, Clone, Copy, PartialEq)]
3075pub struct DecimalSeconds(f64);
3076
3077impl DecimalSeconds {
3078    /// `0.0` — always valid; the natural default.
3079    pub const ZERO: Self = Self(0.0);
3080
3081    /// Validate `value`: finite and non-negative, or
3082    /// [`Error::InvalidDecimalSeconds`].
3083    pub fn new(value: f64) -> Result<Self> {
3084        if value.is_finite() && value >= 0.0 {
3085            Ok(Self(value))
3086        } else {
3087            Err(Error::InvalidDecimalSeconds(value))
3088        }
3089    }
3090
3091    /// The validated seconds value.
3092    #[must_use]
3093    pub fn get(self) -> f64 {
3094        self.0
3095    }
3096
3097    /// Construct without re-validating — only for a value already known
3098    /// valid (this module's own strict-grammar parse, or a literal/`ZERO`).
3099    fn new_unchecked(value: f64) -> Self {
3100        debug_assert!(value.is_finite() && value >= 0.0, "{value} must be valid");
3101        Self(value)
3102    }
3103}
3104
3105impl Default for DecimalSeconds {
3106    fn default() -> Self {
3107        Self::ZERO
3108    }
3109}
3110
3111impl core::fmt::Display for DecimalSeconds {
3112    /// Delegates to `f64`'s own `Display` (not `format_secs`, which is
3113    /// this crate's specific §4.2 wire-syntax renderer) — for a caller
3114    /// that just wants to print the value.
3115    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3116        core::fmt::Display::fmt(&self.0, f)
3117    }
3118}
3119
3120/// As [`DecimalSeconds`], but signed — the one field RFC 8216bis allows to
3121/// be negative (`TIME-OFFSET`, §4.4.2.2: negative means "from the end of
3122/// the last Media Segment").
3123#[derive(Debug, Clone, Copy, PartialEq)]
3124pub struct SignedDecimalSeconds(f64);
3125
3126impl SignedDecimalSeconds {
3127    /// `0.0` — always valid.
3128    pub const ZERO: Self = Self(0.0);
3129
3130    /// Validate `value`: finite, or [`Error::InvalidSignedDecimalSeconds`].
3131    pub fn new(value: f64) -> Result<Self> {
3132        if value.is_finite() {
3133            Ok(Self(value))
3134        } else {
3135            Err(Error::InvalidSignedDecimalSeconds(value))
3136        }
3137    }
3138
3139    /// The validated seconds value.
3140    #[must_use]
3141    pub fn get(self) -> f64 {
3142        self.0
3143    }
3144
3145    /// Construct without re-validating — only for a value already known
3146    /// valid.
3147    fn new_unchecked(value: f64) -> Self {
3148        debug_assert!(value.is_finite(), "{value} must be finite");
3149        Self(value)
3150    }
3151}
3152
3153impl core::fmt::Display for SignedDecimalSeconds {
3154    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3155        core::fmt::Display::fmt(&self.0, f)
3156    }
3157}
3158
3159/// Parse an RFC 8216bis §4.2 decimal-floating-point seconds value
3160/// (`EXTINF`, `PART`/`PART-INF`/`SERVER-CONTROL` durations — all
3161/// non-negative) with the strict lexical grammar [`is_valid_decimal_token`]
3162/// checks, rather than `f64::from_str`'s much wider grammar (issue #1140 /
3163/// audit BH-W4).
3164fn parse_decimal_seconds(
3165    s: &str,
3166    line_no: usize,
3167    line: &str,
3168    what: &str,
3169) -> Result<DecimalSeconds> {
3170    // The grammar in `parse_decimal_float` accepts only finite,
3171    // non-negative values.
3172    parse_decimal_float(s, line_no, line, what, false).map(DecimalSeconds::new_unchecked)
3173}
3174
3175/// As [`parse_decimal_seconds`], but also accepts a leading `-` — used only
3176/// for `#EXT-X-START`'s `TIME-OFFSET` (RFC 8216bis §4.4.2.2, which allows a
3177/// negative offset from the end of the playlist).
3178fn parse_signed_decimal_seconds(
3179    s: &str,
3180    line_no: usize,
3181    line: &str,
3182    what: &str,
3183) -> Result<SignedDecimalSeconds> {
3184    // The grammar in `parse_decimal_float` accepts only finite values.
3185    parse_decimal_float(s, line_no, line, what, true).map(SignedDecimalSeconds::new_unchecked)
3186}
3187
3188/// Split an HLS `<attribute-list>` (RFC 8216bis §4.2: comma-separated
3189/// `AttributeName=AttributeValue` pairs, where a quoted-string value may
3190/// itself contain commas) into a name → value map, plus the set of names
3191/// whose value was written as a quoted-string on the wire.
3192///
3193/// This is the single attribute-list tokenizer for the workspace (issue
3194/// #1140 T12): `ssai-runtime` and `timed-metadata` each carried their own
3195/// copy of this exact honour-quoted-commas algorithm (audit r14-SSAI-O1);
3196/// both now call this function instead. Quoted values are returned with
3197/// their surrounding `"` stripped; unquoted (enumerated-string / decimal)
3198/// values are returned as-is — named/typed attribute extraction (every
3199/// `require_attr`/`.get(...)` call site) reads only the value map (issue
3200/// #1045 / audit BH-C1 — `filter_extra_attrs` uses the quoted-name set to
3201/// build a lossless [`AttrValue`] per unmodeled attribute).
3202pub fn parse_attribute_list(s: &str) -> (BTreeMap<String, String>, BTreeSet<String>) {
3203    scan_attribute_list(s, false).unwrap_or_default()
3204}
3205
3206/// Strict [`parse_attribute_list`]: an unterminated quoted-string is
3207/// [`Error::UnterminatedQuotedString`] and a repeated `AttributeName` is
3208/// [`Error::DuplicateAttribute`] (RFC 8216bis §4.2: "A given AttributeName
3209/// MUST NOT appear more than once in a given AttributeList"). Every typed
3210/// playlist parser in this crate uses this form.
3211pub fn parse_attribute_list_strict(
3212    s: &str,
3213) -> Result<(BTreeMap<String, String>, BTreeSet<String>)> {
3214    scan_attribute_list(s, true)
3215}
3216
3217fn scan_attribute_list(
3218    s: &str,
3219    strict: bool,
3220) -> Result<(BTreeMap<String, String>, BTreeSet<String>)> {
3221    let mut map = BTreeMap::new();
3222    let mut quoted_keys = BTreeSet::new();
3223    let bytes = s.as_bytes();
3224    let len = bytes.len();
3225    let mut i = 0;
3226    while i < len {
3227        while i < len && (bytes[i] == b',' || bytes[i].is_ascii_whitespace()) {
3228            i += 1;
3229        }
3230        if i >= len {
3231            break;
3232        }
3233        let key_start = i;
3234        while i < len && bytes[i] != b'=' {
3235            i += 1;
3236        }
3237        if i >= len {
3238            break;
3239        }
3240        let key = &s[key_start..i];
3241        i += 1;
3242        let quoted = i < len && bytes[i] == b'"';
3243        let value = if quoted {
3244            i += 1;
3245            let value_start = i;
3246            while i < len && bytes[i] != b'"' {
3247                i += 1;
3248            }
3249            let value = &s[value_start..i];
3250            if i < len {
3251                i += 1;
3252            } else if strict {
3253                return Err(Error::UnterminatedQuotedString {
3254                    list: s.to_string(),
3255                });
3256            }
3257            value
3258        } else {
3259            let value_start = i;
3260            while i < len && bytes[i] != b',' {
3261                i += 1;
3262            }
3263            &s[value_start..i]
3264        };
3265        if strict && map.contains_key(key) {
3266            return Err(Error::DuplicateAttribute {
3267                name: key.to_string(),
3268            });
3269        }
3270        map.insert(key.to_string(), value.to_string());
3271        if quoted {
3272            quoted_keys.insert(key.to_string());
3273        }
3274    }
3275    Ok((map, quoted_keys))
3276}
3277
3278/// From an already-parsed attribute map, collect every attribute whose name
3279/// is not in `known` into a `Vec<(name, AttrValue)>`, sorted by name for
3280/// deterministic serialization. `quoted` (from [`parse_attribute_list`]) decides
3281/// [`AttrValue::quoted`] vs. [`AttrValue::bare`] per entry — the recorded
3282/// wire form, not a guess (issue #1045 / audit BH-C1).
3283///
3284/// Fallible because [`AttrValue::quoted`]/[`AttrValue::bare`] validate their
3285/// content (issue #1045 T12); in practice this can only fail for a
3286/// malformed *bare* token whose text happens to contain `"` or whitespace
3287/// (a bare token's own scan stops at the next `,`, so it can hold those; a
3288/// quoted token's content can never contain an unescaped `"`, and neither
3289/// form can span a CR/LF since input is split into lines first).
3290fn filter_extra_attrs(
3291    attrs: &BTreeMap<String, String>,
3292    known: &[&str],
3293    quoted: &BTreeSet<String>,
3294) -> Result<Vec<(String, AttrValue)>> {
3295    attrs
3296        .iter()
3297        .filter(|(k, _)| !known.contains(&k.as_str()))
3298        .map(|(k, v)| {
3299            let value = if quoted.contains(k) {
3300                AttrValue::quoted(v.clone())?
3301            } else {
3302                AttrValue::bare(v.clone())?
3303            };
3304            Ok((k.clone(), value))
3305        })
3306        .collect()
3307}
3308
3309/// Segment-scoped, *segment-defining* tags this crate does not model with a
3310/// typed field (RFC 8216bis §4.4.4.4 `EXT-X-KEY`, §4.4.4.3
3311/// `EXT-X-PROGRAM-DATE-TIME`, §4.4.5.1 `EXT-X-DATERANGE`, and the
3312/// `EXT-X-CUE-*` ad-marker family — a de-facto `EXT-X-CUE-OUT`/`EXT-X-CUE-IN`
3313/// convention the spec text does not define, matched by prefix here):
3314/// per §4.4.1 each applies until the next occurrence or the end of the
3315/// Playlist, so its position relative to segments is load-bearing.
3316/// `MediaPlaylist::parse` attaches such lines to the following segment's
3317/// [`MediaSegment::pre_tags`] instead of hoisting them into the
3318/// playlist-level `extra_tags` block (audit BH-W5, issue #1111).
3319fn is_segment_defining_tag(line: &str) -> bool {
3320    const SEGMENT_SCOPED: &[&str] = &["#EXT-X-KEY:", "#EXT-X-DATERANGE:", "#EXT-X-CUE-"];
3321    SEGMENT_SCOPED.iter().any(|prefix| line.starts_with(prefix))
3322}
3323
3324/// Fetch a required attribute from an already-parsed attribute map, or
3325/// return a contextual [`crate::Error::HlsParse`] naming the missing
3326/// attribute and the owning tag.
3327fn require_attr(
3328    attrs: &BTreeMap<String, String>,
3329    key: &str,
3330    line_no: usize,
3331    line: &str,
3332    tag: &str,
3333) -> Result<String> {
3334    attrs.get(key).cloned().ok_or_else(|| Error::HlsParse {
3335        line_no,
3336        line: line.to_string(),
3337        reason: format!("{tag} missing required {key} attribute"),
3338    })
3339}
3340
3341/// `VIDEO-RANGE` attribute of `#EXT-X-STREAM-INF` (RFC 8216bis §4.4.6.2,
3342/// spec text at `specs/ietf_draft_pantos_hls_rfc8216bis.txt` lines 2786-2815):
3343/// an enumerated bare token describing the dynamic range of the variant's video.
3344///
3345/// `SDR` for CICP transfer characteristics 1, 6, 13, 14, 15; `HLG` for 18;
3346/// `PQ` for 16. Any other code point (including 2 "unspecified" and the
3347/// reserved values) has no mapping and the attribute is omitted (absence
3348/// implies SDR).
3349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3350#[non_exhaustive]
3351pub enum VideoRange {
3352    /// Standard dynamic range (`VIDEO-RANGE=SDR`).
3353    Sdr,
3354    /// Hybrid Log-Gamma (`VIDEO-RANGE=HLG`).
3355    Hlg,
3356    /// Perceptual Quantizer (`VIDEO-RANGE=PQ`).
3357    Pq,
3358}
3359
3360impl VideoRange {
3361    /// The spec token (`"SDR"` / `"HLG"` / `"PQ"`), rendered bare and
3362    /// unquoted on the wire.
3363    pub fn name(&self) -> &'static str {
3364        match self {
3365            VideoRange::Sdr => "SDR",
3366            VideoRange::Hlg => "HLG",
3367            VideoRange::Pq => "PQ",
3368        }
3369    }
3370
3371    /// Map a CICP transfer-characteristics code point (the value returned by
3372    /// `transmux`'s `TransferCharacteristics::code()`) to a [`VideoRange`],
3373    /// per RFC 8216bis §4.4.6.2. Returns `None` for any code point with no
3374    /// defined mapping (including 2 "unspecified" and reserved values), so
3375    /// the caller omits the attribute rather than inventing a value.
3376    pub fn from_transfer_characteristics(code: u8) -> Option<Self> {
3377        // Named CICP transfer-characteristics code points (H.264 Table E-4 /
3378        // H.265 Table E.4), per RFC 8216bis §4.4.6.2.
3379        const TRANSFER_BT709: u8 = 1;
3380        const TRANSFER_BT601: u8 = 6;
3381        const TRANSFER_IEC_61966_2_1: u8 = 13;
3382        const TRANSFER_BT2020_10BIT: u8 = 14;
3383        const TRANSFER_BT2020_12BIT: u8 = 15;
3384        const TRANSFER_SMPTE_ST_2084: u8 = 16;
3385        const TRANSFER_ARIB_STD_B67: u8 = 18;
3386        match code {
3387            TRANSFER_BT709
3388            | TRANSFER_BT601
3389            | TRANSFER_IEC_61966_2_1
3390            | TRANSFER_BT2020_10BIT
3391            | TRANSFER_BT2020_12BIT => Some(Self::Sdr),
3392            TRANSFER_SMPTE_ST_2084 => Some(Self::Pq),
3393            TRANSFER_ARIB_STD_B67 => Some(Self::Hlg),
3394            _ => None,
3395        }
3396    }
3397
3398    /// Build the `("VIDEO-RANGE", AttrValue::bare(...))` extra attribute
3399    /// for a [`Variant`]/[`IFrameVariant`] `extra_attrs` list (or any
3400    /// attribute list assembled for `#EXT-X-STREAM-INF`), per RFC 8216bis
3401    /// §4.4.6.2. The value is the bare enumerated token
3402    /// (`"SDR"`/`"HLG"`/`"PQ"`).
3403    pub fn attr(self) -> (String, AttrValue) {
3404        (
3405            String::from("VIDEO-RANGE"),
3406            AttrValue::bare(self.name()).expect("VIDEO-RANGE token is a valid bare value"),
3407        )
3408    }
3409}
3410
3411broadcast_common::impl_spec_display!(VideoRange);
3412
3413/// `FRAME-RATE` for an `#EXT-X-STREAM-INF` — Apple HLS authoring
3414/// specification item 9.15 (MUST for every variant that includes video) and
3415/// RFC 8216bis §4.4.6.2 (`decimal-floating-point`).
3416///
3417/// Stored as an exact integer number of millihertz (1/1000 Hz), so the wire
3418/// form `FRAME-RATE=<int>.<3 digits>` (e.g. `25.000`, `29.970`,
3419/// `59.940`) is rendered and parsed without any floating-point rounding.
3420/// Construct with [`FrameRate::from_millihertz`].
3421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3422pub struct FrameRate(u32);
3423
3424impl FrameRate {
3425    /// Build a [`FrameRate`] from an exact millihertz value.
3426    #[must_use]
3427    pub fn from_millihertz(millihertz: u32) -> Self {
3428        Self(millihertz)
3429    }
3430
3431    /// The frame rate in millihertz (1/1000 Hz).
3432    #[must_use]
3433    pub fn millihertz(self) -> u32 {
3434        self.0
3435    }
3436}
3437
3438impl core::fmt::Display for FrameRate {
3439    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3440        let whole = self.0 / 1000;
3441        let frac = self.0 % 1000;
3442        write!(f, "{whole}.{frac:03}")
3443    }
3444}
3445
3446impl core::str::FromStr for FrameRate {
3447    type Err = crate::Error;
3448
3449    fn from_str(s: &str) -> Result<Self> {
3450        parse_frame_rate(s, 0, s)
3451    }
3452}
3453
3454/// Media type of an `#EXT-X-MEDIA` rendition (RFC 8216bis §4.4.6.1).
3455#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3456#[non_exhaustive]
3457pub enum MediaType {
3458    /// Audio rendition.
3459    #[default]
3460    Audio,
3461    /// Video rendition.
3462    Video,
3463    /// Subtitle rendition.
3464    Subtitles,
3465    /// Closed-caption rendition (carried in-band; no URI).
3466    ClosedCaptions,
3467}
3468
3469impl MediaType {
3470    /// The spec token (`"AUDIO"` / `"VIDEO"` / `"SUBTITLES"` /
3471    /// `"CLOSED-CAPTIONS"`).
3472    #[must_use]
3473    pub fn name(&self) -> &'static str {
3474        match self {
3475            MediaType::Audio => "AUDIO",
3476            MediaType::Video => "VIDEO",
3477            MediaType::Subtitles => "SUBTITLES",
3478            MediaType::ClosedCaptions => "CLOSED-CAPTIONS",
3479        }
3480    }
3481}
3482
3483broadcast_common::impl_spec_display!(MediaType);
3484
3485/// Typed `INSTREAM-ID` value for an `#EXT-X-MEDIA` rendition
3486/// (RFC 8216bis §4.4.6.1).
3487#[derive(Debug, Clone, PartialEq, Eq)]
3488#[non_exhaustive]
3489pub enum InstreamId {
3490    /// A Line 21 Data Services channel (`"CC1"`..`"CC4"`).
3491    Cc(u8),
3492    /// A Digital Television Closed Captioning service block
3493    /// (`"SERVICE1"`..`"SERVICE63"`).
3494    Service(u8),
3495    /// Any other in-band rendition identifier (non-closed-caption types).
3496    Other(String),
3497}
3498
3499const INSTREAM_CC_PREFIX: &str = "CC";
3500const INSTREAM_SERVICE_PREFIX: &str = "SERVICE";
3501const INSTREAM_CC_RANGE: core::ops::RangeInclusive<u8> = 1..=4;
3502const INSTREAM_SERVICE_RANGE: core::ops::RangeInclusive<u8> = 1..=63;
3503
3504impl InstreamId {
3505    /// Render the wire form.
3506    #[must_use]
3507    pub fn render(&self) -> String {
3508        match self {
3509            InstreamId::Cc(n) => format!("{INSTREAM_CC_PREFIX}{n}"),
3510            InstreamId::Service(n) => format!("{INSTREAM_SERVICE_PREFIX}{n}"),
3511            InstreamId::Other(s) => s.clone(),
3512        }
3513    }
3514
3515    /// Parse a closed-caption `INSTREAM-ID` value, rejecting `CC5+` and
3516    /// `SERVICE0`/`SERVICE64+`.
3517    pub fn parse_closed_captions(value: &str) -> Result<Self> {
3518        let invalid = || Error::InvalidInstreamId {
3519            value: value.to_string(),
3520        };
3521        let canonical_decimal = |digits: &str| -> Option<u8> {
3522            let well_formed = !digits.is_empty()
3523                && digits.bytes().all(|b| b.is_ascii_digit())
3524                && !digits.starts_with('0');
3525            well_formed.then(|| digits.parse().ok()).flatten()
3526        };
3527        if let Some(rest) = value.strip_prefix(INSTREAM_CC_PREFIX) {
3528            return canonical_decimal(rest)
3529                .filter(|n| INSTREAM_CC_RANGE.contains(n))
3530                .map(InstreamId::Cc)
3531                .ok_or_else(invalid);
3532        }
3533        if let Some(rest) = value.strip_prefix(INSTREAM_SERVICE_PREFIX) {
3534            return canonical_decimal(rest)
3535                .filter(|n| INSTREAM_SERVICE_RANGE.contains(n))
3536                .map(InstreamId::Service)
3537                .ok_or_else(invalid);
3538        }
3539        Err(invalid())
3540    }
3541}
3542
3543/// Typed reference to a closed-caption group on a `Variant`
3544/// (RFC 8216bis §4.4.6.2).
3545#[derive(Debug, Clone, PartialEq, Eq)]
3546#[non_exhaustive]
3547pub enum ClosedCaptionsRef {
3548    /// A quoted `GROUP-ID` value referencing an `#EXT-X-MEDIA` group.
3549    Group(String),
3550    /// The enumerated, unquoted token `NONE`.
3551    None,
3552}
3553
3554/// A single `#EXT-X-MEDIA` rendition entry in a multivariant playlist
3555/// (RFC 8216bis §4.4.6.1).
3556#[derive(Debug, Clone, PartialEq, Default)]
3557#[non_exhaustive]
3558pub struct MediaRendition {
3559    /// `TYPE` attribute (REQUIRED).
3560    pub media_type: MediaType,
3561    /// `URI` attribute (optional; MUST NOT be present for closed captions).
3562    pub uri: Option<String>,
3563    /// `GROUP-ID` attribute (REQUIRED, non-empty).
3564    pub group_id: String,
3565    /// `LANGUAGE` attribute.
3566    pub language: Option<String>,
3567    /// `ASSOC-LANGUAGE` attribute.
3568    pub assoc_language: Option<String>,
3569    /// `NAME` attribute (REQUIRED, non-empty).
3570    pub name: String,
3571    /// `STABLE-RENDITION-ID` attribute.
3572    pub stable_rendition_id: Option<String>,
3573    /// `DEFAULT` attribute (absence implies NO).
3574    pub default: bool,
3575    /// `AUTOSELECT` attribute (absence implies NO).
3576    pub autoselect: bool,
3577    /// `FORCED` attribute (absence implies NO; only for subtitles).
3578    pub forced: bool,
3579    /// `INSTREAM-ID` attribute.
3580    pub instream_id: Option<InstreamId>,
3581    /// `BIT-DEPTH` attribute (audio only).
3582    pub bit_depth: Option<u32>,
3583    /// `SAMPLE-RATE` attribute (audio only).
3584    pub sample_rate: Option<u32>,
3585    /// `CHARACTERISTICS` attribute, split into individual MCTs.
3586    pub characteristics: Vec<String>,
3587    /// `CHANNELS` attribute (audio only).
3588    pub channels: Option<String>,
3589    /// Unmodeled attributes, retained for forward-compat / `REQ-` scanning.
3590    pub extra_attrs: Vec<(String, AttrValue)>,
3591}
3592
3593/// A variant stream entry in a master playlist.
3594#[derive(Debug, Clone, PartialEq, Default)]
3595pub struct Variant {
3596    /// `BANDWIDTH` in bits per second.
3597    pub bandwidth: u32,
3598    /// `AVERAGE-BANDWIDTH` in bits per second (RFC 8216bis §4.4.6.2), or
3599    /// `None` when the `#EXT-X-STREAM-INF` carried no `AVERAGE-BANDWIDTH`
3600    /// attribute. Apple's HLS authoring specification item 9.14 requires an
3601    /// authored multivariant playlist to carry it; this crate models the
3602    /// attribute (parse and render) and leaves that obligation to the
3603    /// producer.
3604    pub average_bandwidth: Option<u32>,
3605    /// `FRAME-RATE` as an exact millihertz value (Apple HLS authoring
3606    /// specification item 9.15 — MUST for every variant that includes
3607    /// video), or `None` when the `#EXT-X-STREAM-INF` carried no
3608    /// `FRAME-RATE` attribute. This crate models the attribute (parse and
3609    /// render) and leaves the obligation to the producer; it is emitted
3610    /// only when the stream's H.264 SPS carries VUI `timing_info`
3611    /// (HEVC and codecs without VUI timing omit it, never inventing a
3612    /// value).
3613    pub frame_rate: Option<FrameRate>,
3614    /// `CODECS` — the quoted-string value of the `CODECS` attribute (e.g.
3615    /// `"avc1.64001e,mp4a.40.2"`), or `None` when the `#EXT-X-STREAM-INF`
3616    /// carried no `CODECS` attribute at all (audit BH-W6, issue #1111: it
3617    /// used to default to `""`, so the renderer emitted the invalid
3618    /// `CODECS=""` — a malformed codec list a strict client rejects).
3619    pub codecs: Option<String>,
3620    /// `RESOLUTION` as `(width, height)`, if present.
3621    pub resolution: Option<(u32, u32)>,
3622    /// URI of the media playlist for this variant.
3623    pub uri: String,
3624    /// `AUDIO` attribute: GROUP-ID of the audio rendition group, if any.
3625    pub audio: Option<String>,
3626    /// `VIDEO` attribute: GROUP-ID of the video rendition group, if any.
3627    pub video: Option<String>,
3628    /// `SUBTITLES` attribute: GROUP-ID of the subtitle rendition group, if any.
3629    pub subtitles: Option<String>,
3630    /// `CLOSED-CAPTIONS` attribute: a group reference or `NONE`.
3631    pub closed_captions: Option<ClosedCaptionsRef>,
3632    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
3633    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
3634    pub extra_attrs: Vec<(String, AttrValue)>,
3635}
3636
3637/// An I-frame-only rendition entry for a master playlist — RFC 8216 §4.3.4.2
3638/// (`#EXT-X-I-FRAME-STREAM-INF`).
3639///
3640/// Unlike [`Variant`] / `#EXT-X-STREAM-INF`, the URI is an *attribute* on the
3641/// tag line itself (not on a following line).  Rendered as:
3642/// ```text
3643/// #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=<n>[,CODECS="<c>"][,RESOLUTION=<w>x<h>],URI="<uri>"
3644/// ```
3645#[derive(Debug, Clone, PartialEq, Default)]
3646pub struct IFrameVariant {
3647    /// `BANDWIDTH` in bits per second (required).
3648    pub bandwidth: u32,
3649    /// `AVERAGE-BANDWIDTH` in bits per second (RFC 8216bis §4.4.6.2), or
3650    /// `None` when the `#EXT-X-I-FRAME-STREAM-INF` carried no
3651    /// `AVERAGE-BANDWIDTH` attribute.
3652    pub average_bandwidth: Option<u32>,
3653    /// `CODECS` RFC 6381 string (e.g. `"hvc1.1.6.L93.B0"`).  `None` to omit.
3654    pub codecs: Option<String>,
3655    /// `RESOLUTION` as `(width, height)`.  `None` to omit.
3656    pub resolution: Option<(u32, u32)>,
3657    /// URI of the I-frame-only media playlist.
3658    pub uri: String,
3659    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
3660    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
3661    pub extra_attrs: Vec<(String, AttrValue)>,
3662}
3663
3664/// A master playlist (`#EXTM3U` / `#EXT-X-STREAM-INF` / ...).
3665#[derive(Debug, Clone, PartialEq, Default)]
3666pub struct MasterPlaylist {
3667    /// `#EXT-X-VERSION` — an explicit *floor*, not the rendered value. `0`
3668    /// means "no explicit floor": [`Self::to_m3u8`] renders exactly
3669    /// [`Self::computed_version`], or no tag at all when nothing triggers
3670    /// one. A nonzero value is raised — never lowered — to the computed
3671    /// minimum. See the module's "Protocol version derivation" docs
3672    /// (issue #871).
3673    pub version: u8,
3674    /// Ordered list of `#EXT-X-MEDIA` renditions (RFC 8216bis §4.4.6.1),
3675    /// rendered before the variant streams.
3676    pub media: Vec<MediaRendition>,
3677    /// Ordered list of variant streams.
3678    pub variants: Vec<Variant>,
3679    /// Ordered list of I-frame-only renditions (RFC 8216 §4.3.4.2).
3680    ///
3681    /// Each entry is rendered as an `#EXT-X-I-FRAME-STREAM-INF` line with the
3682    /// URI as an attribute (not a following line).  An empty `Vec` (the
3683    /// default) produces no such lines.
3684    pub iframe_variants: Vec<IFrameVariant>,
3685    /// Extra tag lines emitted **verbatim, first** — immediately after
3686    /// `#EXT-X-VERSION` and before `#EXT-X-INDEPENDENT-SEGMENTS`/`DEFINE`/
3687    /// `START`, the `SESSION-*`/`CONTENT-STEERING` tags, and the
3688    /// variant/I-frame-variant entries (audit BH-W10, issue #1111: this doc
3689    /// previously claimed they were emitted *after* the entries, the inverse
3690    /// of what [`Self::to_m3u8`] does).
3691    ///
3692    /// The Multivariant-Playlist counterpart of
3693    /// [`MediaPlaylist::extra_tags`]. [`Self::parse`] preserves any
3694    /// unrecognized `#EXT-...` tag here (forward-compat) instead of dropping
3695    /// it; [`Self::computed_version`] scans these lines for the §8 rows this
3696    /// crate does not model as typed struct fields (rows 7/12/13 —
3697    /// `docs/version-compatibility.md`). Rows 8 and 11 were also scanned
3698    /// here until issue #872 gave `EXT-X-DEFINE` a typed representation; they
3699    /// now read [`Self::defines`] instead.
3700    pub extra_tags: Vec<String>,
3701    /// `#EXT-X-INDEPENDENT-SEGMENTS` (RFC 8216bis §4.4.2.1, issue #872).
3702    pub independent_segments: bool,
3703    /// `#EXT-X-START` (RFC 8216bis §4.4.2.2, issue #872).
3704    pub start: Option<StartPoint>,
3705    /// `#EXT-X-DEFINE` entries (RFC 8216bis §4.4.2.3, issue #872), in wire
3706    /// order. Feeds §8 row 11 via [`Self::computed_version`].
3707    pub defines: Vec<Define>,
3708    /// `#EXT-X-SESSION-DATA` entries (RFC 8216bis §4.4.6.4, issue #872), in
3709    /// wire order.
3710    pub session_data: Vec<SessionData>,
3711    /// `#EXT-X-SESSION-KEY` entries (RFC 8216bis §4.4.6.5, issue #872), in
3712    /// wire order.
3713    pub session_keys: Vec<SessionKey>,
3714    /// `#EXT-X-CONTENT-STEERING` (RFC 8216bis §4.4.6.6, issue #872) — at most
3715    /// one per Playlist.
3716    pub content_steering: Option<ContentSteering>,
3717}
3718
3719/// A parsed but not-yet-closed `#EXT-X-STREAM-INF` — `(bandwidth,
3720/// average_bandwidth, frame_rate, codecs, resolution, extra attributes)` —
3721/// awaiting the URI line that turns it into a [`Variant`]. `codecs` is `None`
3722/// when the tag carried no `CODECS` attribute (audit BH-W6, issue #1111);
3723/// `average_bandwidth` is `None` when it carried no `AVERAGE-BANDWIDTH`;
3724/// `frame_rate` is `None` when it carried no `FRAME-RATE`.
3725type PendingStreamInf = (
3726    u32,
3727    Option<u32>,
3728    Option<FrameRate>,
3729    Option<String>,
3730    Option<(u32, u32)>,
3731    Vec<(String, AttrValue)>,
3732);
3733
3734impl MasterPlaylist {
3735    /// Render this master playlist as an RFC 8216 `#EXTM3U` string.
3736    ///
3737    /// After the regular `#EXT-X-STREAM-INF` variant lines, emits one
3738    /// `#EXT-X-I-FRAME-STREAM-INF` line per entry in
3739    /// [`Self::iframe_variants`] (RFC 8216 §4.3.4.2).  The URI is rendered
3740    /// as an attribute on the tag line itself — *not* on a following line.
3741    /// # Errors
3742    ///
3743    /// A field this crate renders inside an RFC 8216 §4.2 quoted-string
3744    /// or as a URI — a variant/I-frame-variant URI, a `CODECS`, a
3745    /// `#EXT-X-CONTENT-STEERING` `SERVER-URI`/`PATHWAY-ID`, a
3746    /// `#EXT-X-SESSION-DATA` `DATA-ID`/`VALUE`/`URI`/`LANGUAGE`, a
3747    /// `#EXT-X-SESSION-KEY` `URI`/`KEYFORMAT`/`KEYFORMATVERSIONS`, a
3748    /// `#EXT-X-DEFINE` `NAME`/`VALUE` — that contains `"`, CR or LF is
3749    /// rejected before any of it reaches the output (audit BH-W7, issue
3750    /// #1111): those three are forbidden in a quoted-string, and a
3751    /// newline would let a value inject a tag line into the playlist.
3752    pub fn to_m3u8(&self) -> Result<String> {
3753        self.validate_for_render()?;
3754        let mut s = String::new();
3755        s.push_str("#EXTM3U\n");
3756        if let Some(version) = self.effective_version() {
3757            s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
3758        }
3759
3760        for tag in &self.extra_tags {
3761            s.push_str(tag);
3762            s.push('\n');
3763        }
3764
3765        // #EXT-X-MEDIA renditions (RFC 8216bis §4.4.6.1), before variants.
3766        for m in &self.media {
3767            s.push_str("#EXT-X-MEDIA:TYPE=");
3768            s.push_str(m.media_type.name());
3769            if let Some(uri) = &m.uri {
3770                validate_uri(uri, "EXT-X-MEDIA URI")?;
3771                s.push_str(&format!(",URI=\"{uri}\""));
3772            }
3773            s.push_str(&format!(",GROUP-ID=\"{}\"", m.group_id));
3774            if let Some(l) = &m.language {
3775                validate_quoted_string(l, "EXT-X-MEDIA LANGUAGE")?;
3776                s.push_str(&format!(",LANGUAGE=\"{l}\""));
3777            }
3778            if let Some(l) = &m.assoc_language {
3779                validate_quoted_string(l, "EXT-X-MEDIA ASSOC-LANGUAGE")?;
3780                s.push_str(&format!(",ASSOC-LANGUAGE=\"{l}\""));
3781            }
3782            validate_quoted_string(&m.name, "EXT-X-MEDIA NAME")?;
3783            s.push_str(&format!(",NAME=\"{}\"", m.name));
3784            if let Some(sri) = &m.stable_rendition_id {
3785                validate_quoted_string(sri, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
3786                s.push_str(&format!(",STABLE-RENDITION-ID=\"{sri}\""));
3787            }
3788            s.push_str(&format!(
3789                ",DEFAULT={}",
3790                if m.default { "YES" } else { "NO" }
3791            ));
3792            if m.autoselect || !m.default {
3793                s.push_str(&format!(
3794                    ",AUTOSELECT={}",
3795                    if m.autoselect { "YES" } else { "NO" }
3796                ));
3797            }
3798            if m.forced {
3799                s.push_str(",FORCED=YES");
3800            } else if m.media_type == MediaType::Subtitles {
3801                // Preserve explicit NO for subtitles to mirror fixtures? Keep NO always for losslessness.
3802                s.push_str(",FORCED=NO");
3803            }
3804            if let Some(ii) = &m.instream_id {
3805                let v = ii.render();
3806                validate_quoted_string(&v, "EXT-X-MEDIA INSTREAM-ID")?;
3807                s.push_str(&format!(",INSTREAM-ID=\"{v}\""));
3808            }
3809            if let Some(bd) = m.bit_depth {
3810                s.push_str(&format!(",BIT-DEPTH={bd}"));
3811            }
3812            if let Some(sr) = m.sample_rate {
3813                s.push_str(&format!(",SAMPLE-RATE={sr}"));
3814            }
3815            if !m.characteristics.is_empty() {
3816                let joined = m.characteristics.join(",");
3817                validate_quoted_string(&joined, "EXT-X-MEDIA CHARACTERISTICS")?;
3818                s.push_str(&format!(",CHARACTERISTICS=\"{joined}\""));
3819            }
3820            if let Some(ch) = &m.channels {
3821                validate_quoted_string(ch, "EXT-X-MEDIA CHANNELS")?;
3822                s.push_str(&format!(",CHANNELS=\"{ch}\""));
3823            }
3824            render_attribute_list(&mut s, &m.extra_attrs);
3825            s.push('\n');
3826        }
3827
3828        // §4.4.2 tags (issue #872).
3829        if self.independent_segments {
3830            s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
3831        }
3832        for def in &self.defines {
3833            push_define_line(&mut s, def)?;
3834        }
3835        if let Some(start) = &self.start {
3836            push_start_line(&mut s, start);
3837        }
3838        // §4.4.6.4/.5/.6 Multivariant Playlist tags (issue #872).
3839        for sk in &self.session_keys {
3840            push_session_key_line(&mut s, sk)?;
3841        }
3842        for sd in &self.session_data {
3843            push_session_data_line(&mut s, sd)?;
3844        }
3845        if let Some(cs) = &self.content_steering {
3846            validate_uri(&cs.server_uri, "EXT-X-CONTENT-STEERING SERVER-URI")?;
3847            s.push_str(&format!(
3848                "#EXT-X-CONTENT-STEERING:SERVER-URI=\"{}\"",
3849                cs.server_uri
3850            ));
3851            if let Some(pid) = &cs.pathway_id {
3852                validate_quoted_string(pid, "EXT-X-CONTENT-STEERING PATHWAY-ID")?;
3853                s.push_str(&format!(",PATHWAY-ID=\"{pid}\""));
3854            }
3855            render_attribute_list(&mut s, &cs.extra_attrs);
3856            s.push('\n');
3857        }
3858
3859        for var in &self.variants {
3860            s.push_str(&format!("#EXT-X-STREAM-INF:BANDWIDTH={}", var.bandwidth));
3861            // RFC 8216bis §4.4.6.2: AVERAGE-BANDWIDTH renders immediately
3862            // after BANDWIDTH; an absent value renders nothing.
3863            if let Some(avg) = var.average_bandwidth {
3864                s.push_str(&format!(",AVERAGE-BANDWIDTH={avg}"));
3865            }
3866            // Audit BH-W6 (issue #1111): CODECS is optional on the tag line,
3867            // so an absent attribute renders nothing rather than the invalid
3868            // empty `CODECS=""`.
3869            if let Some(codecs) = &var.codecs {
3870                validate_quoted_string(codecs, "EXT-X-STREAM-INF CODECS")?;
3871                s.push_str(&format!(",CODECS=\"{codecs}\""));
3872            }
3873            if let Some((w, h)) = var.resolution {
3874                s.push_str(&format!(",RESOLUTION={w}x{h}"));
3875            }
3876            // Apple HLS authoring specification item 9.15 / RFC 8216bis
3877            // §4.4.6.2: FRAME-RATE renders after RESOLUTION as an exact
3878            // 3-decimal millihertz value.
3879            if let Some(frame_rate) = var.frame_rate {
3880                s.push_str(&format!(",FRAME-RATE={frame_rate}"));
3881            }
3882            if let Some(g) = &var.audio {
3883                validate_quoted_string(g, "EXT-X-STREAM-INF AUDIO")?;
3884                s.push_str(&format!(",AUDIO=\"{g}\""));
3885            }
3886            if let Some(g) = &var.video {
3887                validate_quoted_string(g, "EXT-X-STREAM-INF VIDEO")?;
3888                s.push_str(&format!(",VIDEO=\"{g}\""));
3889            }
3890            if let Some(g) = &var.subtitles {
3891                validate_quoted_string(g, "EXT-X-STREAM-INF SUBTITLES")?;
3892                s.push_str(&format!(",SUBTITLES=\"{g}\""));
3893            }
3894            match &var.closed_captions {
3895                Some(ClosedCaptionsRef::Group(g)) => {
3896                    validate_quoted_string(g, "EXT-X-STREAM-INF CLOSED-CAPTIONS")?;
3897                    s.push_str(&format!(",CLOSED-CAPTIONS=\"{g}\""));
3898                }
3899                Some(ClosedCaptionsRef::None) => {
3900                    s.push_str(",CLOSED-CAPTIONS=NONE");
3901                }
3902                None => {}
3903            }
3904            render_attribute_list(&mut s, &var.extra_attrs);
3905            s.push('\n');
3906            validate_uri(&var.uri, "variant URI")?;
3907            s.push_str(&var.uri);
3908            s.push('\n');
3909        }
3910
3911        // I-frame-only renditions — RFC 8216 §4.3.4.2.
3912        // URI is an attribute on the tag line, not a following URI line.
3913        for iv in &self.iframe_variants {
3914            s.push_str(&format!(
3915                "#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH={}",
3916                iv.bandwidth
3917            ));
3918            // RFC 8216bis §4.4.6.2: AVERAGE-BANDWIDTH renders immediately
3919            // after BANDWIDTH; an absent value renders nothing.
3920            if let Some(avg) = iv.average_bandwidth {
3921                s.push_str(&format!(",AVERAGE-BANDWIDTH={avg}"));
3922            }
3923            if let Some(c) = &iv.codecs {
3924                s.push_str(&format!(",CODECS=\"{c}\""));
3925            }
3926            if let Some((w, h)) = iv.resolution {
3927                s.push_str(&format!(",RESOLUTION={w}x{h}"));
3928            }
3929            validate_uri(&iv.uri, "I-frame-variant URI")?;
3930            if let Some(codecs) = &iv.codecs {
3931                validate_quoted_string(codecs, "EXT-X-I-FRAME-STREAM-INF CODECS")?;
3932            }
3933            render_attribute_list(&mut s, &iv.extra_attrs);
3934            s.push_str(&format!(",URI=\"{}\"\n", iv.uri));
3935        }
3936
3937        Ok(s)
3938    }
3939
3940    /// Reject any field that would be rendered inside an RFC 8216 §4.2
3941    /// quoted-string or as a URI but contains `"`, CR or LF (audit BH-W7,
3942    /// issue #1111) — a tag/attribute-list injection vector for a playlist
3943    /// assembled from external data. Covers every field [`Self::to_m3u8`]
3944    /// emits; the per-tag renderers re-check the same values, so a caller
3945    /// that bypasses this method still cannot emit a malformed line.
3946    fn validate_for_render(&self) -> Result<()> {
3947        validate_tag_body("playlist-level extra tag", &self.extra_tags)?;
3948        for def in &self.defines {
3949            push_define_line(&mut String::new(), def)?;
3950        }
3951        if let Some(cs) = &self.content_steering {
3952            validate_uri(&cs.server_uri, "EXT-X-CONTENT-STEERING SERVER-URI")?;
3953            if let Some(pid) = &cs.pathway_id {
3954                validate_quoted_string(pid, "EXT-X-CONTENT-STEERING PATHWAY-ID")?;
3955            }
3956        }
3957        for var in &self.variants {
3958            validate_uri(&var.uri, "variant URI")?;
3959            if let Some(codecs) = &var.codecs {
3960                validate_quoted_string(codecs, "EXT-X-STREAM-INF CODECS")?;
3961            }
3962        }
3963        for iv in &self.iframe_variants {
3964            validate_uri(&iv.uri, "I-frame-variant URI")?;
3965            if let Some(codecs) = &iv.codecs {
3966                validate_quoted_string(codecs, "EXT-X-I-FRAME-STREAM-INF CODECS")?;
3967            }
3968        }
3969        check_rendition_groups(&self.media)?;
3970        // EXT-X-MEDIA rendition validation (RFC 8216bis §4.4.6.1).
3971        for m in &self.media {
3972            if m.group_id.is_empty() {
3973                return Err(Error::HlsParse {
3974                    line_no: 0,
3975                    line: String::new(),
3976                    reason: "EXT-X-MEDIA GROUP-ID must be non-empty".to_string(),
3977                });
3978            }
3979            validate_quoted_string(&m.group_id, "EXT-X-MEDIA GROUP-ID")?;
3980            if m.name.is_empty() {
3981                return Err(Error::HlsParse {
3982                    line_no: 0,
3983                    line: String::new(),
3984                    reason: "EXT-X-MEDIA NAME must be non-empty".to_string(),
3985                });
3986            }
3987            validate_quoted_string(&m.name, "EXT-X-MEDIA NAME")?;
3988            if m.media_type == MediaType::ClosedCaptions {
3989                if m.uri.is_some() {
3990                    return Err(Error::HlsParse {
3991                        line_no: 0,
3992                        line: String::new(),
3993                        reason: "EXT-X-MEDIA URI MUST NOT be present when TYPE is CLOSED-CAPTIONS"
3994                            .to_string(),
3995                    });
3996                }
3997                if m.instream_id.is_none() {
3998                    return Err(Error::HlsParse {
3999                        line_no: 0,
4000                        line: String::new(),
4001                        reason: "EXT-X-MEDIA INSTREAM-ID REQUIRED when TYPE is CLOSED-CAPTIONS"
4002                            .to_string(),
4003                    });
4004                }
4005            } else {
4006                // URI allowed; instream_id optional
4007            }
4008            if m.forced && m.media_type != MediaType::Subtitles {
4009                return Err(Error::HlsParse {
4010                    line_no: 0,
4011                    line: String::new(),
4012                    reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES"
4013                        .to_string(),
4014                });
4015            }
4016            if m.bit_depth.is_some() && m.media_type != MediaType::Audio {
4017                return Err(Error::HlsParse {
4018                    line_no: 0,
4019                    line: String::new(),
4020                    reason: "EXT-X-MEDIA BIT-DEPTH MUST NOT be present unless TYPE is AUDIO"
4021                        .to_string(),
4022                });
4023            }
4024            if m.sample_rate.is_some() && m.media_type != MediaType::Audio {
4025                return Err(Error::HlsParse {
4026                    line_no: 0,
4027                    line: String::new(),
4028                    reason: "EXT-X-MEDIA SAMPLE-RATE MUST NOT be present unless TYPE is AUDIO"
4029                        .to_string(),
4030                });
4031            }
4032            if m.channels.is_some() && m.media_type != MediaType::Audio {
4033                return Err(Error::HlsParse {
4034                    line_no: 0,
4035                    line: String::new(),
4036                    reason: "EXT-X-MEDIA CHANNELS MUST NOT be present unless TYPE is AUDIO"
4037                        .to_string(),
4038                });
4039            }
4040            if let Some(uri) = &m.uri {
4041                validate_uri(uri, "EXT-X-MEDIA URI")?;
4042            }
4043            if let Some(sri) = &m.stable_rendition_id {
4044                validate_quoted_string(sri, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
4045            }
4046            if let Some(ii) = &m.instream_id {
4047                let v = ii.render();
4048                validate_quoted_string(&v, "EXT-X-MEDIA INSTREAM-ID")?;
4049                let closed_captions = m.media_type == MediaType::ClosedCaptions;
4050                let cc_form = !matches!(ii, InstreamId::Other(_));
4051                if closed_captions != cc_form {
4052                    return Err(Error::InstreamIdTypeMismatch {
4053                        media_type: m.media_type,
4054                        value: v,
4055                    });
4056                }
4057                if closed_captions {
4058                    InstreamId::parse_closed_captions(&v)?;
4059                }
4060            }
4061            if let Some(ch) = &m.channels {
4062                validate_quoted_string(ch, "EXT-X-MEDIA CHANNELS")?;
4063            }
4064        }
4065        // Group cross-check + NONE consistency.
4066        let none_used = self
4067            .variants
4068            .iter()
4069            .any(|v| matches!(v.closed_captions, Some(ClosedCaptionsRef::None)));
4070        if none_used {
4071            for v in &self.variants {
4072                if !matches!(v.closed_captions, Some(ClosedCaptionsRef::None)) {
4073                    return Err(Error::HlsParse {
4074                        line_no: 0,
4075                        line: String::new(),
4076                        reason:
4077                            "EXT-X-STREAM-INF CLOSED-CAPTIONS=NONE must be used on all variants"
4078                                .to_string(),
4079                    });
4080                }
4081            }
4082        }
4083        let group_exists = |gid: &str, mt: MediaType| -> bool {
4084            self.media
4085                .iter()
4086                .any(|m| m.group_id == gid && m.media_type == mt)
4087        };
4088        for v in &self.variants {
4089            if let Some(g) = &v.audio
4090                && !group_exists(g, MediaType::Audio)
4091            {
4092                return Err(Error::HlsParse {
4093                    line_no: 0,
4094                    line: String::new(),
4095                    reason: format!("EXT-X-STREAM-INF AUDIO group {g:?} not found"),
4096                });
4097            }
4098            if let Some(g) = &v.video
4099                && !group_exists(g, MediaType::Video)
4100            {
4101                return Err(Error::HlsParse {
4102                    line_no: 0,
4103                    line: String::new(),
4104                    reason: format!("EXT-X-STREAM-INF VIDEO group {g:?} not found"),
4105                });
4106            }
4107            if let Some(g) = &v.subtitles
4108                && !group_exists(g, MediaType::Subtitles)
4109            {
4110                return Err(Error::HlsParse {
4111                    line_no: 0,
4112                    line: String::new(),
4113                    reason: format!("EXT-X-STREAM-INF SUBTITLES group {g:?} not found"),
4114                });
4115            }
4116            if let Some(ClosedCaptionsRef::Group(g)) = &v.closed_captions
4117                && !group_exists(g, MediaType::ClosedCaptions)
4118            {
4119                return Err(Error::HlsParse {
4120                    line_no: 0,
4121                    line: String::new(),
4122                    reason: format!("EXT-X-STREAM-INF CLOSED-CAPTIONS group {g:?} not found"),
4123                });
4124            }
4125        }
4126        for sk in &self.session_keys {
4127            push_session_key_line(&mut String::new(), sk)?;
4128        }
4129        for sd in &self.session_data {
4130            push_session_data_line(&mut String::new(), sd)?;
4131        }
4132        Ok(())
4133    }
4134
4135    /// Parse an RFC 8216 `#EXTM3U` Multivariant (Master) Playlist — the
4136    /// symmetric inverse of [`Self::to_m3u8`].
4137    ///
4138    /// Recognizes `#EXT-X-VERSION`, `#EXT-X-STREAM-INF` + its following URI
4139    /// line, and `#EXT-X-I-FRAME-STREAM-INF`. `#EXT-X-MEDIA` (alternate
4140    /// audio/subtitle renditions), `#EXT-X-DEFINE`, and any other
4141    /// unrecognized `#EXT-...` tag are not modeled with typed fields, but
4142    /// are preserved verbatim into [`Self::extra_tags`] (forward-compat),
4143    /// mirroring [`MediaPlaylist::parse`]. A malformed
4144    /// `#EXT-X-STREAM-INF`/`#EXT-X-I-FRAME-STREAM-INF` (missing required
4145    /// attribute, unparsable value) or a variant URI with no preceding
4146    /// `#EXT-X-STREAM-INF` returns [`crate::Error::HlsParse`].
4147    pub fn parse(input: &str) -> Result<Self> {
4148        // `0` (not `1`): see the identical comment in `MediaPlaylist::parse`.
4149        let mut version: u8 = 0;
4150        let mut variants: Vec<Variant> = Vec::new();
4151        let mut iframe_variants: Vec<IFrameVariant> = Vec::new();
4152        let mut media: Vec<MediaRendition> = Vec::new();
4153        let mut extra_tags: Vec<String> = Vec::new();
4154        let mut saw_extm3u = false;
4155        let mut pending_stream_inf: Option<PendingStreamInf> = None;
4156        // §4.4.2/§4.4.6.4/.5/.6 accumulators (issue #872).
4157        let mut independent_segments = false;
4158        let mut start: Option<StartPoint> = None;
4159        let mut defines: Vec<Define> = Vec::new();
4160        let mut session_data: Vec<SessionData> = Vec::new();
4161        let mut session_keys: Vec<SessionKey> = Vec::new();
4162        let mut content_steering: Option<ContentSteering> = None;
4163
4164        for (idx, raw_line) in input.lines().enumerate() {
4165            let line_no = idx + 1;
4166            let mut line = raw_line.trim_end_matches('\r');
4167            if line_no == 1 {
4168                line = line.strip_prefix('\u{feff}').unwrap_or(line);
4169            }
4170            let line = line.trim();
4171            if line.is_empty() {
4172                continue;
4173            }
4174
4175            if line == "#EXTM3U" {
4176                saw_extm3u = true;
4177            } else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
4178                version = parse_decimal_int(rest, line_no, line, "EXT-X-VERSION")?;
4179            } else if let Some(rest) = line.strip_prefix("#EXT-X-STREAM-INF:") {
4180                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4181                let bandwidth_str =
4182                    require_attr(&attrs, "BANDWIDTH", line_no, line, "EXT-X-STREAM-INF")?;
4183                let bandwidth = parse_decimal_int(&bandwidth_str, line_no, line, "BANDWIDTH")?;
4184                // RFC 8216bis §4.4.6.2: AVERAGE-BANDWIDTH is a
4185                // decimal-integer, optional on parse (Apple item 9.14 asks
4186                // the producer to emit it).
4187                let average_bandwidth = match attrs.get("AVERAGE-BANDWIDTH") {
4188                    Some(v) => Some(parse_decimal_int(v, line_no, line, "AVERAGE-BANDWIDTH")?),
4189                    None => None,
4190                };
4191                // RFC 8216bis §4.4.6.2: FRAME-RATE is a decimal-floating-point
4192                // value, rounded to millihertz (3 decimals) on parse.
4193                let frame_rate = match attrs.get("FRAME-RATE") {
4194                    Some(v) => Some(parse_frame_rate(v, line_no, line)?),
4195                    None => None,
4196                };
4197                // Audit BH-W6 (issue #1111): `CODECS` is optional on the tag
4198                // line, so an absent attribute stays `None` (rendering
4199                // nothing) instead of becoming an empty, invalid `CODECS=""`.
4200                let codecs = attrs.get("CODECS").cloned();
4201                let resolution = match attrs.get("RESOLUTION") {
4202                    Some(v) => Some(parse_resolution(v, line_no, line)?),
4203                    None => None,
4204                };
4205                let extra_attrs = filter_extra_attrs(
4206                    &attrs,
4207                    &[
4208                        "BANDWIDTH",
4209                        "AVERAGE-BANDWIDTH",
4210                        "CODECS",
4211                        "RESOLUTION",
4212                        "FRAME-RATE",
4213                    ],
4214                    &quoted,
4215                )?;
4216                // Audit BH-W9 (issue #1111): `#EXT-X-STREAM-INF` MUST be
4217                // followed by the URI line of the Variant it describes
4218                // (RFC 8216bis §4.4.6.2). A second STREAM-INF before that
4219                // line means the first Variant's URI is missing — the
4220                // playlist is malformed, and silently overwriting *loses*
4221                // a variant (which no downstream validator can recover,
4222                // since the information is gone by the time it sees the
4223                // parsed struct).
4224                if pending_stream_inf.is_some() {
4225                    return Err(Error::HlsParse {
4226                        line_no,
4227                        line: line.to_string(),
4228                        reason: "second #EXT-X-STREAM-INF before the first Variant's URI \
4229                                 line (RFC 8216bis §4.4.6.2)"
4230                            .to_string(),
4231                    });
4232                }
4233                pending_stream_inf = Some((
4234                    bandwidth,
4235                    average_bandwidth,
4236                    frame_rate,
4237                    codecs,
4238                    resolution,
4239                    extra_attrs,
4240                ));
4241            } else if let Some(rest) = line.strip_prefix("#EXT-X-I-FRAME-STREAM-INF:") {
4242                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4243                let bandwidth_str = require_attr(
4244                    &attrs,
4245                    "BANDWIDTH",
4246                    line_no,
4247                    line,
4248                    "EXT-X-I-FRAME-STREAM-INF",
4249                )?;
4250                let bandwidth = parse_decimal_int(&bandwidth_str, line_no, line, "BANDWIDTH")?;
4251                let average_bandwidth = match attrs.get("AVERAGE-BANDWIDTH") {
4252                    Some(v) => Some(parse_decimal_int(v, line_no, line, "AVERAGE-BANDWIDTH")?),
4253                    None => None,
4254                };
4255                let codecs = attrs.get("CODECS").cloned();
4256                let resolution = match attrs.get("RESOLUTION") {
4257                    Some(v) => Some(parse_resolution(v, line_no, line)?),
4258                    None => None,
4259                };
4260                let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-I-FRAME-STREAM-INF")?;
4261                let extra_attrs = filter_extra_attrs(
4262                    &attrs,
4263                    &[
4264                        "BANDWIDTH",
4265                        "AVERAGE-BANDWIDTH",
4266                        "CODECS",
4267                        "RESOLUTION",
4268                        "URI",
4269                    ],
4270                    &quoted,
4271                )?;
4272                iframe_variants.push(IFrameVariant {
4273                    bandwidth,
4274                    average_bandwidth,
4275                    codecs,
4276                    resolution,
4277                    uri,
4278                    extra_attrs,
4279                });
4280            } else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
4281                independent_segments = true;
4282            } else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
4283                start = Some(parse_start(rest, line_no, line)?);
4284            } else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
4285                defines.push(parse_define(rest, line_no, line)?);
4286            } else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-DATA:") {
4287                session_data.push(parse_session_data(rest, line_no, line)?);
4288            } else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-KEY:") {
4289                session_keys.push(parse_session_key(rest, line_no, line)?);
4290            } else if let Some(rest) = line.strip_prefix("#EXT-X-CONTENT-STEERING:") {
4291                let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4292                let server_uri = require_attr(
4293                    &attrs,
4294                    "SERVER-URI",
4295                    line_no,
4296                    line,
4297                    "EXT-X-CONTENT-STEERING",
4298                )?;
4299                let pathway_id = attrs.get("PATHWAY-ID").cloned();
4300                let extra_attrs =
4301                    filter_extra_attrs(&attrs, &["SERVER-URI", "PATHWAY-ID"], &quoted)?;
4302                content_steering = Some(ContentSteering {
4303                    server_uri,
4304                    pathway_id,
4305                    extra_attrs,
4306                });
4307            } else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA:") {
4308                media.push(parse_media_rendition(rest, line_no, line)?);
4309            } else if let Some(rest) = line.strip_prefix("#EXT") {
4310                let _ = rest;
4311                // A well-formed but still-unmodeled tag (e.g. #EXT-X-MEDIA):
4312                // preserve verbatim (forward-compat, and the substrate
4313                // `computed_version` scans for §8 rows 7/12/13), mirroring
4314                // `MediaPlaylist::parse`. NOTE: this arm must stay LAST of
4315                // the `#EXT` arms — every typed arm above is a more specific
4316                // prefix and would otherwise be shadowed by it.
4317                extra_tags.push(line.to_string());
4318            } else if line.starts_with('#') {
4319                // RFC 8216 §4.1: a non-"#EXT" '#' line is a comment — ignore.
4320            } else {
4321                let (bandwidth, average_bandwidth, frame_rate, codecs, resolution, extra_attrs) =
4322                    pending_stream_inf.take().ok_or_else(|| Error::HlsParse {
4323                        line_no,
4324                        line: line.to_string(),
4325                        reason: "variant URI with no preceding #EXT-X-STREAM-INF".to_string(),
4326                    })?;
4327                // Pull the typed rendition-group references out of the
4328                // unmodeled attribute bag (issue #1428).
4329                let mut audio = None;
4330                let mut video = None;
4331                let mut subtitles = None;
4332                let mut closed_captions = None;
4333                let mut extra_attrs_filtered = Vec::new();
4334                for (k, v) in extra_attrs {
4335                    match k.as_str() {
4336                        "AUDIO" => audio = Some(v.as_str().to_string()),
4337                        "VIDEO" => video = Some(v.as_str().to_string()),
4338                        "SUBTITLES" => subtitles = Some(v.as_str().to_string()),
4339                        "CLOSED-CAPTIONS" => {
4340                            if v.as_str() == "NONE" {
4341                                closed_captions = Some(ClosedCaptionsRef::None);
4342                            } else {
4343                                closed_captions =
4344                                    Some(ClosedCaptionsRef::Group(v.as_str().to_string()));
4345                            }
4346                        }
4347                        _ => extra_attrs_filtered.push((k, v)),
4348                    }
4349                }
4350                variants.push(Variant {
4351                    bandwidth,
4352                    average_bandwidth,
4353                    frame_rate,
4354                    codecs,
4355                    resolution,
4356                    uri: line.to_string(),
4357                    audio,
4358                    video,
4359                    subtitles,
4360                    closed_captions,
4361                    extra_attrs: extra_attrs_filtered,
4362                });
4363            }
4364        }
4365
4366        if !saw_extm3u {
4367            return Err(Error::HlsParse {
4368                line_no: 1,
4369                line: String::new(),
4370                reason: "missing #EXTM3U header".to_string(),
4371            });
4372        }
4373        // Audit BH-W9 (issue #1111): a trailing `#EXT-X-STREAM-INF` with no
4374        // URI line is a truncated playlist, not a variant silently dropped.
4375        if pending_stream_inf.is_some() {
4376            return Err(Error::HlsParse {
4377                line_no: 0,
4378                line: String::new(),
4379                reason: "#EXT-X-STREAM-INF with no following Variant URI line (RFC 8216bis \
4380                         §4.4.6.2)"
4381                    .to_string(),
4382            });
4383        }
4384
4385        check_rendition_groups(&media)?;
4386
4387        Ok(MasterPlaylist {
4388            version,
4389            media,
4390            variants,
4391            iframe_variants,
4392            extra_tags,
4393            independent_segments,
4394            start,
4395            defines,
4396            session_data,
4397            session_keys,
4398            content_steering,
4399        })
4400    }
4401
4402    /// Compute the minimum `#EXT-X-VERSION` this Multivariant Playlist's
4403    /// actual content requires, per RFC 8216bis §8
4404    /// (`docs/version-compatibility.md`). `None` means fully compatible
4405    /// with version 1 (no tag required). See
4406    /// [`MediaPlaylist::computed_version`] for the Media-Playlist-only rows
4407    /// (2–6, 9–10) that do not apply to a Multivariant Playlist.
4408    pub fn computed_version(&self) -> Option<u8> {
4409        // Rows 7/13 (SERVICE INSTREAM-ID, non-CC INSTREAM-ID) — all
4410        // attributes of `EXT-X-MEDIA` or of tags this crate still does not
4411        // model, so `extra_tags` remains the substrate. Row 12 on *unmodeled*
4412        // tags is handled here; row 12 on modeled tags is checked below.
4413        let mut v = scan_tag_lines_for_version(&self.extra_tags);
4414
4415        // Typed EXT-X-MEDIA renditions (issue #1428): drive the same §8
4416        // version floor the verbatim scanner did when these lines sat in
4417        // `extra_tags`.
4418        for m in &self.media {
4419            if let Some(InstreamId::Service(_)) = m.instream_id {
4420                bump_version(&mut v, VERSION_MEDIA_SERVICE_INSTREAM_ID);
4421            }
4422            if m.instream_id.is_some() && m.media_type != MediaType::ClosedCaptions {
4423                bump_version(&mut v, VERSION_MEDIA_INSTREAM_ID_NON_CC);
4424            }
4425        }
4426
4427        // Row 11: EXT-X-DEFINE with a QUERYPARAM attribute — typed since
4428        // issue #872 (see `MediaPlaylist::computed_version` for why the
4429        // string scan can no longer see this tag).
4430        if self
4431            .defines
4432            .iter()
4433            .any(|d| matches!(d, Define::QueryParam { .. }))
4434        {
4435            bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
4436        }
4437
4438        // Row 8: variable substitution across every string this playlist
4439        // carries — opaque tag lines, variant/I-frame-variant URIs, and the
4440        // typed #872 fields (EXT-X-DEFINE values, EXT-X-SESSION-DATA
4441        // VALUE/URI, EXT-X-SESSION-KEY URI, EXT-X-CONTENT-STEERING URIs).
4442        if self
4443            .extra_tags
4444            .iter()
4445            .any(|t| contains_variable_substitution(t))
4446            || self.media.iter().any(|m| {
4447                m.uri
4448                    .as_ref()
4449                    .is_some_and(|u| contains_variable_substitution(u))
4450                    || contains_variable_substitution(&m.group_id)
4451                    || contains_variable_substitution(&m.name)
4452                    || m.language
4453                        .as_ref()
4454                        .is_some_and(|x| contains_variable_substitution(x))
4455                    || m.assoc_language
4456                        .as_ref()
4457                        .is_some_and(|x| contains_variable_substitution(x))
4458                    || m.stable_rendition_id
4459                        .as_ref()
4460                        .is_some_and(|x| contains_variable_substitution(x))
4461                    || m.channels
4462                        .as_ref()
4463                        .is_some_and(|x| contains_variable_substitution(x))
4464                    || m.characteristics
4465                        .iter()
4466                        .any(|x| contains_variable_substitution(x))
4467            })
4468            || self
4469                .variants
4470                .iter()
4471                .any(|var| contains_variable_substitution(&var.uri))
4472            || self
4473                .iframe_variants
4474                .iter()
4475                .any(|iv| contains_variable_substitution(&iv.uri))
4476            || self.master_playlist_typed_strings_use_substitution()
4477        {
4478            bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
4479        }
4480
4481        // Row 12: REQ- attribute on any typed struct — modeled tags that
4482        // carry extra_attrs fields since issue #884.
4483        if any_typed_req_attr(
4484            self.start.as_ref(),
4485            &self.defines,
4486            &self.session_data,
4487            &self.session_keys,
4488            self.content_steering.as_ref(),
4489            &self.media,
4490            &self.variants,
4491            &self.iframe_variants,
4492        ) {
4493            bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
4494        }
4495
4496        v
4497    }
4498
4499    /// Row 8 helper — the Multivariant-Playlist counterpart of
4500    /// [`MediaPlaylist::media_playlist_typed_strings_use_substitution`],
4501    /// covering the string-bearing tags issue #872 gave typed
4502    /// representations (before which they sat in `extra_tags` and were
4503    /// covered by the opaque scan).
4504    fn master_playlist_typed_strings_use_substitution(&self) -> bool {
4505        let define_values = self.defines.iter().filter_map(|d| match d {
4506            Define::Name { value, .. } => Some(value),
4507            _ => None,
4508        });
4509        let session_data_strings = self.session_data.iter().map(|sd| match &sd.content {
4510            SessionDataContent::Value(v) => v,
4511            SessionDataContent::Uri { uri, .. } => uri,
4512        });
4513        let session_key_uris = self.session_keys.iter().filter_map(|k| k.uri.as_ref());
4514        let steering_uris = self.content_steering.iter().map(|cs| &cs.server_uri);
4515
4516        define_values
4517            .chain(session_data_strings)
4518            .chain(session_key_uris)
4519            .chain(steering_uris)
4520            .any(|s| contains_variable_substitution(s))
4521    }
4522
4523    /// The `#EXT-X-VERSION` value actually rendered by [`Self::to_m3u8`] —
4524    /// see [`MediaPlaylist::effective_version`] for the shared rule.
4525    fn effective_version(&self) -> Option<u8> {
4526        effective_version(self.version, self.computed_version())
4527    }
4528}
4529
4530/// Parse a `FRAME-RATE=<decimal-floating-point>` attribute value into an exact
4531/// millihertz [`FrameRate`], rounding half-up to three decimal places without
4532/// any floating-point arithmetic; any number of fraction digits is accepted
4533/// (RFC 8216bis §4.2 `decimal-floating-point` has no digit limit).
4534fn parse_frame_rate(v: &str, line_no: usize, line: &str) -> Result<FrameRate> {
4535    let t = v.trim();
4536    if !is_valid_decimal_token(t, false) {
4537        return Err(Error::HlsParse {
4538            line_no,
4539            line: line.to_string(),
4540            reason: format!("FRAME-RATE value {v:?} is not a valid decimal number"),
4541        });
4542    }
4543    let (int_part, frac_part) = match t.split_once('.') {
4544        Some((i, f)) => (i, Some(f)),
4545        None => (t, None),
4546    };
4547    let out_of_range = || Error::HlsParse {
4548        line_no,
4549        line: line.to_string(),
4550        reason: format!("FRAME-RATE value {v:?} is out of range"),
4551    };
4552    let int_value: u32 = int_part.parse().map_err(|_| out_of_range())?;
4553    let mut digits = frac_part.unwrap_or("").bytes().map(|b| u32::from(b - b'0'));
4554    let mut next = || digits.next().unwrap_or(0);
4555    let milli = next() * 100 + next() * 10 + next();
4556    let round_up = u32::from(next() >= 5);
4557    let total = int_value
4558        .checked_mul(1000)
4559        .and_then(|i| i.checked_add(milli + round_up))
4560        .ok_or_else(out_of_range)?;
4561    Ok(FrameRate::from_millihertz(total))
4562}
4563
4564/// Parse a `RESOLUTION=<w>x<h>` attribute value.
4565fn parse_resolution(v: &str, line_no: usize, line: &str) -> Result<(u32, u32)> {
4566    let mut split = v.splitn(2, 'x');
4567    let w = split.next().unwrap_or("");
4568    let h = split.next().ok_or_else(|| Error::HlsParse {
4569        line_no,
4570        line: line.to_string(),
4571        reason: format!("RESOLUTION value {v:?} is not of the form <width>x<height>"),
4572    })?;
4573    let width = parse_decimal_int(w, line_no, line, "RESOLUTION width")?;
4574    let height = parse_decimal_int(h, line_no, line, "RESOLUTION height")?;
4575    Ok((width, height))
4576}
4577
4578fn parse_yes_no(attrs: &BTreeMap<String, String>, attribute: &'static str) -> Result<Option<bool>> {
4579    match attrs.get(attribute).map(String::as_str) {
4580        None => Ok(None),
4581        Some("YES") => Ok(Some(true)),
4582        Some("NO") => Ok(Some(false)),
4583        Some(other) => Err(Error::InvalidYesNo {
4584            attribute,
4585            value: other.to_string(),
4586        }),
4587    }
4588}
4589
4590fn check_rendition_groups(media: &[MediaRendition]) -> Result<()> {
4591    let mut seen: BTreeSet<(&str, &str, &str)> = BTreeSet::new();
4592    let mut defaults: BTreeSet<(&str, &str)> = BTreeSet::new();
4593    for m in media {
4594        let group = (m.media_type.name(), m.group_id.as_str());
4595        if !seen.insert((m.media_type.name(), m.group_id.as_str(), m.name.as_str())) {
4596            return Err(Error::DuplicateNameInGroup {
4597                media_type: m.media_type,
4598                group_id: m.group_id.clone(),
4599                name: m.name.clone(),
4600            });
4601        }
4602        if m.default && !defaults.insert(group) {
4603            return Err(Error::DuplicateDefaultInGroup {
4604                media_type: m.media_type,
4605                group_id: m.group_id.clone(),
4606            });
4607        }
4608    }
4609    Ok(())
4610}
4611
4612fn parse_media_rendition(rest: &str, line_no: usize, line: &str) -> Result<MediaRendition> {
4613    let (attrs, quoted) = parse_attribute_list_strict(rest)?;
4614
4615    // TYPE (REQUIRED)
4616    let type_str = require_attr(&attrs, "TYPE", line_no, line, "EXT-X-MEDIA")?;
4617    let media_type = match type_str.as_str() {
4618        "AUDIO" => MediaType::Audio,
4619        "VIDEO" => MediaType::Video,
4620        "SUBTITLES" => MediaType::Subtitles,
4621        "CLOSED-CAPTIONS" => MediaType::ClosedCaptions,
4622        _ => {
4623            return Err(Error::HlsParse {
4624                line_no,
4625                line: line.to_string(),
4626                reason: format!("EXT-X-MEDIA invalid TYPE {type_str:?}"),
4627            });
4628        }
4629    };
4630
4631    let group_id = require_attr(&attrs, "GROUP-ID", line_no, line, "EXT-X-MEDIA")?;
4632    if group_id.is_empty() {
4633        return Err(Error::HlsParse {
4634            line_no,
4635            line: line.to_string(),
4636            reason: "EXT-X-MEDIA GROUP-ID must be non-empty".to_string(),
4637        });
4638    }
4639    validate_quoted_string(&group_id, "EXT-X-MEDIA GROUP-ID")?;
4640
4641    let name = require_attr(&attrs, "NAME", line_no, line, "EXT-X-MEDIA")?;
4642    if name.is_empty() {
4643        return Err(Error::HlsParse {
4644            line_no,
4645            line: line.to_string(),
4646            reason: "EXT-X-MEDIA NAME must be non-empty".to_string(),
4647        });
4648    }
4649    validate_quoted_string(&name, "EXT-X-MEDIA NAME")?;
4650
4651    // URI
4652    let uri = attrs.get("URI").cloned();
4653    if media_type == MediaType::ClosedCaptions && uri.is_some() {
4654        return Err(Error::HlsParse {
4655            line_no,
4656            line: line.to_string(),
4657            reason: "EXT-X-MEDIA URI MUST NOT be present when TYPE is CLOSED-CAPTIONS".to_string(),
4658        });
4659    }
4660    if let Some(u) = &uri {
4661        validate_uri(u, "EXT-X-MEDIA URI")?;
4662    }
4663
4664    let language = attrs.get("LANGUAGE").cloned();
4665    let assoc_language = attrs.get("ASSOC-LANGUAGE").cloned();
4666    if let Some(l) = &language {
4667        validate_quoted_string(l, "EXT-X-MEDIA LANGUAGE")?;
4668    }
4669    if let Some(l) = &assoc_language {
4670        validate_quoted_string(l, "EXT-X-MEDIA ASSOC-LANGUAGE")?;
4671    }
4672
4673    let stable_rendition_id = attrs.get("STABLE-RENDITION-ID").cloned();
4674    if let Some(s) = &stable_rendition_id {
4675        validate_quoted_string(s, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
4676    }
4677
4678    // DEFAULT / AUTOSELECT
4679    let default = parse_yes_no(&attrs, "DEFAULT")?.unwrap_or(false);
4680    let autoselect_attr = parse_yes_no(&attrs, "AUTOSELECT")?;
4681    let autoselect = autoselect_attr.unwrap_or(false);
4682    if default && autoselect_attr == Some(false) {
4683        return Err(Error::HlsParse {
4684            line_no,
4685            line: line.to_string(),
4686            reason: "EXT-X-MEDIA AUTOSELECT MUST be YES when DEFAULT is YES".to_string(),
4687        });
4688    }
4689
4690    // FORCED
4691    let forced = parse_yes_no(&attrs, "FORCED")?.unwrap_or(false);
4692    if forced && media_type != MediaType::Subtitles {
4693        // FORCED MUST NOT be present unless TYPE is SUBTITLES; treat YES as violation
4694        return Err(Error::HlsParse {
4695            line_no,
4696            line: line.to_string(),
4697            reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES".to_string(),
4698        });
4699    }
4700    // Also reject FORCED present for non-subtitles even if NO? Spec: MUST NOT be present.
4701    if attrs.contains_key("FORCED") && media_type != MediaType::Subtitles {
4702        return Err(Error::HlsParse {
4703            line_no,
4704            line: line.to_string(),
4705            reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES".to_string(),
4706        });
4707    }
4708
4709    // INSTREAM-ID
4710    let instream_id = if let Some(v) = attrs.get("INSTREAM-ID") {
4711        validate_quoted_string(v, "EXT-X-MEDIA INSTREAM-ID")?;
4712        if media_type == MediaType::ClosedCaptions {
4713            Some(InstreamId::parse_closed_captions(v)?)
4714        } else {
4715            Some(InstreamId::Other(v.clone()))
4716        }
4717    } else {
4718        None
4719    };
4720    if media_type == MediaType::ClosedCaptions && instream_id.is_none() {
4721        return Err(Error::HlsParse {
4722            line_no,
4723            line: line.to_string(),
4724            reason: "EXT-X-MEDIA INSTREAM-ID REQUIRED when TYPE is CLOSED-CAPTIONS".to_string(),
4725        });
4726    }
4727
4728    // BIT-DEPTH / SAMPLE-RATE / CHANNELS only for AUDIO
4729    let bit_depth = if let Some(v) = attrs.get("BIT-DEPTH") {
4730        if media_type != MediaType::Audio {
4731            return Err(Error::HlsParse {
4732                line_no,
4733                line: line.to_string(),
4734                reason: "EXT-X-MEDIA BIT-DEPTH MUST NOT be present unless TYPE is AUDIO"
4735                    .to_string(),
4736            });
4737        }
4738        Some(parse_decimal_int(v, line_no, line, "BIT-DEPTH")?)
4739    } else {
4740        None
4741    };
4742    let sample_rate = if let Some(v) = attrs.get("SAMPLE-RATE") {
4743        if media_type != MediaType::Audio {
4744            return Err(Error::HlsParse {
4745                line_no,
4746                line: line.to_string(),
4747                reason: "EXT-X-MEDIA SAMPLE-RATE MUST NOT be present unless TYPE is AUDIO"
4748                    .to_string(),
4749            });
4750        }
4751        Some(parse_decimal_int(v, line_no, line, "SAMPLE-RATE")?)
4752    } else {
4753        None
4754    };
4755    let channels = if let Some(v) = attrs.get("CHANNELS") {
4756        if media_type != MediaType::Audio {
4757            return Err(Error::HlsParse {
4758                line_no,
4759                line: line.to_string(),
4760                reason: "EXT-X-MEDIA CHANNELS MUST NOT be present unless TYPE is AUDIO".to_string(),
4761            });
4762        }
4763        validate_quoted_string(v, "EXT-X-MEDIA CHANNELS")?;
4764        Some(v.clone())
4765    } else {
4766        None
4767    };
4768
4769    // CHARACTERISTICS
4770    let characteristics = attrs
4771        .get("CHARACTERISTICS")
4772        .map(|v| {
4773            validate_quoted_string(v, "EXT-X-MEDIA CHARACTERISTICS")?;
4774            Ok(v.split(',')
4775                .map(|s| s.trim().to_string())
4776                .filter(|s| !s.is_empty())
4777                .collect::<Vec<_>>())
4778        })
4779        .transpose()?
4780        .unwrap_or_default();
4781
4782    // extra_attrs
4783    const KNOWN: &[&str] = &[
4784        "TYPE",
4785        "URI",
4786        "GROUP-ID",
4787        "LANGUAGE",
4788        "ASSOC-LANGUAGE",
4789        "NAME",
4790        "STABLE-RENDITION-ID",
4791        "DEFAULT",
4792        "AUTOSELECT",
4793        "FORCED",
4794        "INSTREAM-ID",
4795        "BIT-DEPTH",
4796        "SAMPLE-RATE",
4797        "CHARACTERISTICS",
4798        "CHANNELS",
4799    ];
4800    let extra_attrs = filter_extra_attrs(&attrs, KNOWN, &quoted)?;
4801
4802    // INSTREAM-ID version row 13 for non-CC is handled in version derivation.
4803
4804    Ok(MediaRendition {
4805        media_type,
4806        uri,
4807        group_id,
4808        language,
4809        assoc_language,
4810        name,
4811        stable_rendition_id,
4812        default,
4813        autoselect,
4814        forced,
4815        instream_id,
4816        bit_depth,
4817        sample_rate,
4818        characteristics,
4819        channels,
4820        extra_attrs,
4821    })
4822}
4823
4824/// Auto-detect init-segment changes across a sequence of segments and mark the
4825/// first segment that follows an init change as discontinuous (RFC 8216 §4.3.4.3).
4826///
4827/// `entries` is an ordered list of `(init_bytes, segment)` pairs — one per
4828/// media segment in playlist order. For each segment after the first, if its
4829/// init bytes differ from the preceding segment's, `segment.discontinuous` is
4830/// set to `true`. The first segment is never marked (no preceding context).
4831///
4832/// This is the building block for playlist assemblers that splice content from
4833/// multiple sources with different `EXT-X-MAP` init segments: detect changes
4834/// once, then pass the updated `MediaSegment` list to [`MediaPlaylist`].
4835///
4836/// # Example
4837/// ```
4838/// use broadcast_hls::{mark_init_discontinuities, DecimalSeconds, MediaSegment};
4839/// let init_a = b"moov_a" as &[u8];
4840/// let init_b = b"moov_b" as &[u8];
4841/// let five_secs = DecimalSeconds::new(5.0).unwrap();
4842/// let mut seg0 = MediaSegment { uri: "s0.m4s".into(), duration: five_secs, discontinuous: false, parts: vec![], ..Default::default() };
4843/// let mut seg1 = MediaSegment { uri: "s1.m4s".into(), duration: five_secs, discontinuous: false, parts: vec![], ..Default::default() };
4844/// let mut seg2 = MediaSegment { uri: "s2.m4s".into(), duration: five_secs, discontinuous: false, parts: vec![], ..Default::default() };
4845/// let mut entries: Vec<(&[u8], &mut MediaSegment)> = vec![
4846///     (init_a, &mut seg0),
4847///     (init_b, &mut seg1),
4848///     (init_b, &mut seg2),
4849/// ];
4850/// mark_init_discontinuities(&mut entries);
4851/// assert!(!entries[0].1.discontinuous);
4852/// assert!(entries[1].1.discontinuous);   // init changed: a → b
4853/// assert!(!entries[2].1.discontinuous);  // same init
4854/// ```
4855pub fn mark_init_discontinuities(entries: &mut [(&[u8], &mut MediaSegment)]) {
4856    if entries.len() < 2 {
4857        return;
4858    }
4859    // Walk the slice as a sliding window: [prev | cur..].
4860    // `split_at_mut` gives two non-overlapping sub-slices so we can hold an
4861    // immutable read of `prev.0` while mutating `cur.1.discontinuous`.
4862    for i in 1..entries.len() {
4863        let (head, tail) = entries.split_at_mut(i);
4864        let prev_init: &[u8] = head[i - 1].0;
4865        let cur = &mut tail[0];
4866        if cur.0 != prev_init {
4867            cur.1.discontinuous = true;
4868        }
4869    }
4870}
4871
4872#[cfg(test)]
4873mod tests {
4874    use super::*;
4875
4876    fn seg(uri: &str, duration: f64) -> MediaSegment {
4877        MediaSegment {
4878            uri: uri.into(),
4879            duration: DecimalSeconds::new(duration).unwrap(),
4880            discontinuous: false,
4881            parts: vec![],
4882            ..Default::default()
4883        }
4884    }
4885
4886    fn seg_disc(uri: &str, duration: f64) -> MediaSegment {
4887        MediaSegment {
4888            uri: uri.into(),
4889            duration: DecimalSeconds::new(duration).unwrap(),
4890            discontinuous: true,
4891            parts: vec![],
4892            ..Default::default()
4893        }
4894    }
4895
4896    fn playlist(segments: Vec<MediaSegment>) -> MediaPlaylist {
4897        MediaPlaylist {
4898            version: 3,
4899            target_duration: 10,
4900            media_sequence: 0,
4901            discontinuity_sequence: 0,
4902            segments,
4903            endlist: true,
4904            extra_tags: vec![],
4905            low_latency: None,
4906            iframes_only: false,
4907            open_segment: None,
4908            ..Default::default()
4909        }
4910    }
4911
4912    #[test]
4913    fn media_playlist_basic() {
4914        let pl = MediaPlaylist {
4915            version: 3,
4916            target_duration: 10,
4917            media_sequence: 0,
4918            discontinuity_sequence: 0,
4919            segments: vec![
4920                seg("seg0.m4s", 9.009),
4921                seg("seg1.m4s", 9.009),
4922                seg("seg2.m4s", 3.003),
4923            ],
4924            endlist: true,
4925            extra_tags: vec![],
4926            low_latency: None,
4927            iframes_only: false,
4928            open_segment: None,
4929            ..Default::default()
4930        };
4931        let out = pl.to_m3u8().unwrap();
4932        assert!(out.starts_with("#EXTM3U\n"));
4933        assert!(out.contains("#EXT-X-TARGETDURATION:10\n"));
4934        assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:0\n"));
4935        assert_eq!(out.matches("#EXTINF:").count(), 3);
4936        assert!(out.ends_with("#EXT-X-ENDLIST\n"));
4937        // No discontinuity sequence when 0.
4938        assert!(!out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"));
4939    }
4940
4941    #[test]
4942    fn media_playlist_no_endlist() {
4943        let pl = MediaPlaylist {
4944            version: 7,
4945            target_duration: 6,
4946            media_sequence: 42,
4947            discontinuity_sequence: 0,
4948            segments: vec![seg("seg.m4s", 6.000)],
4949            endlist: false,
4950            extra_tags: vec![],
4951            low_latency: None,
4952            iframes_only: false,
4953            open_segment: None,
4954            ..Default::default()
4955        };
4956        let out = pl.to_m3u8().unwrap();
4957        assert!(out.starts_with("#EXTM3U\n"));
4958        assert!(out.contains("#EXT-X-VERSION:7\n"));
4959        assert!(!out.contains("#EXT-X-ENDLIST"));
4960    }
4961
4962    #[test]
4963    fn master_playlist_basic() {
4964        let pl = MasterPlaylist {
4965            version: 6,
4966            media: vec![],
4967            variants: vec![
4968                Variant {
4969                    bandwidth: 300_000,
4970                    average_bandwidth: None,
4971                    frame_rate: None,
4972                    codecs: Some("avc1.64001e,mp4a.40.2".into()),
4973                    resolution: Some((640, 360)),
4974                    uri: "v300/index.m3u8".into(),
4975                    ..Default::default()
4976                },
4977                Variant {
4978                    bandwidth: 800_000,
4979                    average_bandwidth: None,
4980                    frame_rate: None,
4981                    codecs: Some("avc1.640028,mp4a.40.2".into()),
4982                    resolution: Some((1280, 720)),
4983                    uri: "v800/index.m3u8".into(),
4984                    ..Default::default()
4985                },
4986            ],
4987            iframe_variants: vec![],
4988            ..Default::default()
4989        };
4990        let out = pl.to_m3u8().unwrap();
4991        assert!(out.starts_with("#EXTM3U\n"));
4992        assert_eq!(out.matches("#EXT-X-STREAM-INF:").count(), 2);
4993        assert!(out.contains("v300/index.m3u8"));
4994        assert!(out.contains("v800/index.m3u8"));
4995        assert!(out.contains("RESOLUTION=640x360"));
4996        assert!(out.contains("RESOLUTION=1280x720"));
4997    }
4998
4999    #[test]
5000    fn master_playlist_no_resolution() {
5001        let pl = MasterPlaylist {
5002            version: 6,
5003            media: vec![],
5004            variants: vec![Variant {
5005                bandwidth: 1_000_000,
5006                average_bandwidth: None,
5007                frame_rate: None,
5008                codecs: Some("avc1.640028".into()),
5009                resolution: None,
5010                uri: "v1k/index.m3u8".into(),
5011                audio: None,
5012                video: None,
5013                subtitles: None,
5014                closed_captions: None,
5015                extra_attrs: Vec::new(),
5016            }],
5017            iframe_variants: vec![],
5018            ..Default::default()
5019        };
5020        let out = pl.to_m3u8().unwrap();
5021        assert!(!out.contains("RESOLUTION"));
5022        assert!(out.contains("#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.640028\""));
5023    }
5024
5025    /// RFC 8216bis §4.4.6.2: `AVERAGE-BANDWIDTH` renders immediately after
5026    /// `BANDWIDTH`, and a `Variant` built with the attribute round-trips
5027    /// (render -> parse) with the same value.
5028    #[test]
5029    fn master_playlist_renders_average_bandwidth_after_bandwidth() {
5030        let pl = MasterPlaylist {
5031            media: vec![],
5032            variants: vec![Variant {
5033                bandwidth: 2_523_597,
5034                average_bandwidth: Some(2_190_673),
5035                frame_rate: None,
5036                codecs: Some("avc1.640020,mp4a.40.2".into()),
5037                uri: "v540/index.m3u8".into(),
5038                ..Default::default()
5039            }],
5040            ..Default::default()
5041        };
5042        let out = pl.to_m3u8().unwrap();
5043        assert!(
5044            out.contains(
5045                "#EXT-X-STREAM-INF:BANDWIDTH=2523597,AVERAGE-BANDWIDTH=2190673,\
5046                 CODECS=\"avc1.640020,mp4a.40.2\""
5047            ),
5048            "out: {out}"
5049        );
5050        let reparsed = MasterPlaylist::parse(&out).unwrap();
5051        assert_eq!(reparsed.variants, pl.variants, "render -> parse must equal");
5052        assert_eq!(reparsed.variants[0].average_bandwidth, Some(2_190_673));
5053    }
5054
5055    /// A `Variant` with no `AVERAGE-BANDWIDTH` renders no attribute and
5056    /// round-trips as `None` (the attribute is optional on parse).
5057    #[test]
5058    fn master_playlist_omits_absent_average_bandwidth_and_round_trips_none() {
5059        let pl = MasterPlaylist {
5060            media: vec![],
5061            variants: vec![Variant {
5062                bandwidth: 800_000,
5063                average_bandwidth: None,
5064                frame_rate: None,
5065                codecs: Some("avc1.640028,mp4a.40.2".into()),
5066                uri: "v800/index.m3u8".into(),
5067                ..Default::default()
5068            }],
5069            ..Default::default()
5070        };
5071        let out = pl.to_m3u8().unwrap();
5072        assert!(!out.contains("AVERAGE-BANDWIDTH"), "out: {out}");
5073        let reparsed = MasterPlaylist::parse(&out).unwrap();
5074        assert_eq!(reparsed.variants, pl.variants, "render -> parse must equal");
5075        assert_eq!(reparsed.variants[0].average_bandwidth, None);
5076    }
5077
5078    /// A non-decimal `AVERAGE-BANDWIDTH` is a structured parse error, like
5079    /// `BANDWIDTH` (RFC 8216bis §4.2 `decimal-integer`).
5080    #[test]
5081    fn average_bandwidth_must_be_a_decimal_integer() {
5082        let text =
5083            "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=12x\nv/index.m3u8\n";
5084        let err = MasterPlaylist::parse(text).unwrap_err();
5085        match err {
5086            Error::HlsParse { reason, .. } => {
5087                assert!(reason.contains("AVERAGE-BANDWIDTH"), "reason: {reason}");
5088            }
5089            other => panic!("expected HlsParse, got {other:?}"),
5090        }
5091    }
5092
5093    /// RFC 8216bis §4.4.6.2: `AVERAGE-BANDWIDTH` on
5094    /// `#EXT-X-I-FRAME-STREAM-INF` renders immediately after `BANDWIDTH` and
5095    /// round-trips (render -> parse) with the same value.
5096    #[test]
5097    fn iframe_variant_renders_average_bandwidth_after_bandwidth() {
5098        let pl = MasterPlaylist {
5099            iframe_variants: vec![IFrameVariant {
5100                bandwidth: 200_000,
5101                average_bandwidth: Some(150_000),
5102                codecs: Some("hvc1.1.6.L93.B0".into()),
5103                resolution: Some((320, 240)),
5104                uri: "iframe.m3u8".into(),
5105                extra_attrs: Vec::new(),
5106            }],
5107            ..Default::default()
5108        };
5109        let out = pl.to_m3u8().unwrap();
5110        assert!(
5111            out.contains("#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=200000,AVERAGE-BANDWIDTH=150000,"),
5112            "out: {out}"
5113        );
5114        let reparsed = MasterPlaylist::parse(&out).unwrap();
5115        assert_eq!(reparsed.iframe_variants, pl.iframe_variants);
5116        assert_eq!(reparsed.iframe_variants[0].average_bandwidth, Some(150_000));
5117    }
5118
5119    /// An I-frame variant with no `AVERAGE-BANDWIDTH` renders no attribute
5120    /// and round-trips as `None`.
5121    #[test]
5122    fn iframe_variant_omits_absent_average_bandwidth_and_round_trips_none() {
5123        let pl = MasterPlaylist {
5124            iframe_variants: vec![IFrameVariant {
5125                bandwidth: 200_000,
5126                average_bandwidth: None,
5127                codecs: Some("hvc1.1.6.L93.B0".into()),
5128                resolution: Some((320, 240)),
5129                uri: "iframe.m3u8".into(),
5130                extra_attrs: Vec::new(),
5131            }],
5132            ..Default::default()
5133        };
5134        let out = pl.to_m3u8().unwrap();
5135        assert!(!out.contains("AVERAGE-BANDWIDTH"), "out: {out}");
5136        let reparsed = MasterPlaylist::parse(&out).unwrap();
5137        assert_eq!(reparsed.iframe_variants, pl.iframe_variants);
5138        assert_eq!(reparsed.iframe_variants[0].average_bandwidth, None);
5139    }
5140
5141    /// A parsed I-frame `#EXT-X-I-FRAME-STREAM-INF` line carrying
5142    /// `AVERAGE-BANDWIDTH` exposes it on the typed field and NOT in
5143    /// `extra_attrs`.
5144    #[test]
5145    fn iframe_variant_average_bandwidth_not_retained_in_extra_attrs() {
5146        let text = "#EXTM3U\n#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=1200,URI=\"i.m3u8\"\n";
5147        let pl = MasterPlaylist::parse(text).unwrap();
5148        assert_eq!(pl.iframe_variants[0].average_bandwidth, Some(1200));
5149        assert!(
5150            pl.iframe_variants[0].extra_attrs.is_empty(),
5151            "extra_attrs: {:?}",
5152            pl.iframe_variants[0].extra_attrs
5153        );
5154        assert!(
5155            !pl.iframe_variants[0]
5156                .extra_attrs
5157                .iter()
5158                .any(|(k, _)| k == "AVERAGE-BANDWIDTH")
5159        );
5160    }
5161
5162    /// A non-decimal `AVERAGE-BANDWIDTH` on an I-frame variant is a
5163    /// structured parse error, like `BANDWIDTH`.
5164    #[test]
5165    fn iframe_average_bandwidth_must_be_a_decimal_integer() {
5166        let text = "#EXTM3U\n#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=12x,URI=\"i.m3u8\"\n";
5167        let err = MasterPlaylist::parse(text).unwrap_err();
5168        match err {
5169            Error::HlsParse { reason, .. } => {
5170                assert!(reason.contains("AVERAGE-BANDWIDTH"), "reason: {reason}");
5171            }
5172            other => panic!("expected HlsParse, got {other:?}"),
5173        }
5174    }
5175
5176    /// RFC 8216bis §4.4.6.2 / Apple 9.15: `FRAME-RATE` renders after
5177    /// `RESOLUTION` as an exact 3-decimal millihertz value and round-trips.
5178    #[test]
5179    fn master_playlist_renders_frame_rate_after_resolution() {
5180        let pl = MasterPlaylist {
5181            media: vec![],
5182            variants: vec![Variant {
5183                bandwidth: 1_000_000,
5184                average_bandwidth: None,
5185                codecs: Some("avc1.640028".into()),
5186                resolution: Some((1280, 720)),
5187                frame_rate: Some(FrameRate::from_millihertz(29_970)),
5188                uri: "v/index.m3u8".into(),
5189                ..Default::default()
5190            }],
5191            ..Default::default()
5192        };
5193        let out = pl.to_m3u8().unwrap();
5194        assert!(
5195            out.contains("RESOLUTION=1280x720,FRAME-RATE=29.970"),
5196            "out: {out}"
5197        );
5198        let reparsed = MasterPlaylist::parse(&out).unwrap();
5199        assert_eq!(reparsed.variants, pl.variants);
5200        assert_eq!(
5201            reparsed.variants[0].frame_rate,
5202            Some(FrameRate::from_millihertz(29_970))
5203        );
5204    }
5205
5206    /// A `Variant` with no `FRAME-RATE` renders no attribute and round-trips
5207    /// as `None`.
5208    #[test]
5209    fn master_playlist_omits_absent_frame_rate_and_round_trips_none() {
5210        let pl = MasterPlaylist {
5211            media: vec![],
5212            variants: vec![Variant {
5213                bandwidth: 800_000,
5214                average_bandwidth: None,
5215                codecs: Some("avc1.640028".into()),
5216                resolution: None,
5217                frame_rate: None,
5218                uri: "v/index.m3u8".into(),
5219                ..Default::default()
5220            }],
5221            ..Default::default()
5222        };
5223        let out = pl.to_m3u8().unwrap();
5224        assert!(!out.contains("FRAME-RATE"), "out: {out}");
5225        let reparsed = MasterPlaylist::parse(&out).unwrap();
5226        assert_eq!(reparsed.variants[0].frame_rate, None);
5227    }
5228
5229    /// Parse `FRAME-RATE` decimal-floating-point values, rounding half-up to
5230    /// three decimals into millihertz.
5231    #[test]
5232    fn frame_rate_parses_various_forms() {
5233        for (text, expected) in [
5234            ("30", 30_000u32),
5235            ("29.97", 29_970u32),
5236            ("29.9704", 29_970u32),
5237        ] {
5238            let src = format!(
5239                "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,FRAME-RATE={text}\nv/index.m3u8\n"
5240            );
5241            let parsed = MasterPlaylist::parse(&src).unwrap();
5242            assert_eq!(
5243                parsed.variants[0].frame_rate,
5244                Some(FrameRate::from_millihertz(expected)),
5245                "text={text}"
5246            );
5247        }
5248    }
5249
5250    /// `FRAME-RATE` parse rejects empty / negative / exponent / non-numeric
5251    /// values with a structured `HlsParse` error naming the attribute.
5252    #[test]
5253    fn frame_rate_rejects_invalid_values() {
5254        for value in ["", "-1", "1e3", "abc", "1.2.3"] {
5255            let src = format!(
5256                "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,FRAME-RATE={value}\nv/index.m3u8\n"
5257            );
5258            let err = MasterPlaylist::parse(&src).unwrap_err();
5259            match err {
5260                Error::HlsParse { reason, .. } => {
5261                    assert!(
5262                        reason.contains("FRAME-RATE"),
5263                        "value={value:?} reason={reason}"
5264                    );
5265                }
5266                other => panic!("value={value:?} expected HlsParse, got {other:?}"),
5267            }
5268        }
5269    }
5270
5271    /// The 3-decimal `#EXTINF` form (`9.009` — what every RFC 8216 example
5272    /// and every existing consumer expects) is kept for genuinely fractional
5273    /// durations.
5274    #[test]
5275    fn extinf_three_decimals() {
5276        let pl = playlist(vec![seg("s.m4s", 9.009)]);
5277        let out = pl.to_m3u8().unwrap();
5278        assert!(out.contains("#EXTINF:9.009,\n"), "{out}");
5279    }
5280
5281    /// RFC 8216bis §8 row 3: "A Media Playlist MUST indicate an
5282    /// EXT-X-VERSION of 3 or higher if it contains: Floating-point EXTINF
5283    /// duration values." The requirement is about what the playlist
5284    /// **contains**, so a whole number of seconds must render as an integer
5285    /// — otherwise `to_m3u8` emitted `#EXTINF:9.000,` (a floating-point
5286    /// value) while `computed_version` reported `None`, telling a v1/v2
5287    /// client the playlist was compatible with it and then handing it a
5288    /// float it cannot parse.
5289    ///
5290    /// MUTATION VERIFIED: restoring the old `format!("{v:.3}")`-first body
5291    /// of `format_extinf` makes the `#EXTINF:9,` assertion below fail
5292    /// (`9.000` is rendered instead). Recompiled and re-run to confirm,
5293    /// then reverted.
5294    #[test]
5295    fn integral_extinf_renders_as_an_integer_and_needs_no_version() {
5296        // `version: 0` — no explicit floor, so the rendered tag (or its
5297        // absence) is exactly what the derivation asks for.
5298        let pl = MediaPlaylist {
5299            version: 0,
5300            ..playlist(vec![seg("s.m4s", 9.0)])
5301        };
5302        let out = pl.to_m3u8().unwrap();
5303        assert!(out.contains("#EXTINF:9,\n"), "{out}");
5304        assert!(!out.contains("9.000"), "{out}");
5305        assert_eq!(
5306            pl.computed_version(),
5307            None,
5308            "a playlist with no floating-point EXTINF trips no §8 row: {out}"
5309        );
5310        assert!(!out.contains("#EXT-X-VERSION"), "{out}");
5311        // ...and it still re-parses to the identical f64.
5312        let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
5313        assert_eq!(
5314            reparsed.segments[0].duration,
5315            DecimalSeconds::new(9.0).unwrap()
5316        );
5317    }
5318
5319    /// The renderer and the §8 row-3 predicate must never disagree about
5320    /// whether a duration is floating-point — the divergence that caused the
5321    /// bug above. Pins the exact four values from the report, plus the
5322    /// sub-millisecond case that diverged in the *other* direction (the
5323    /// old integer-millisecond predicate called `4.0004` integral while
5324    /// issue #882's precision fallback rendered it as `4.0004`).
5325    #[test]
5326    fn extinf_rendering_and_version_derivation_never_diverge() {
5327        for (duration, expected_text, expected_version) in [
5328            (4.0_f64, "#EXTINF:4,", None),
5329            (4.004, "#EXTINF:4.004,", Some(3)),
5330            (9.9766, "#EXTINF:9.9766,", Some(3)), // issue #882 regression guard
5331            (4.0004, "#EXTINF:4.0004,", Some(3)), // sub-ms: predicate used to say None
5332        ] {
5333            let pl = playlist(vec![seg("s.m4s", duration)]);
5334            let out = pl.to_m3u8().unwrap();
5335            assert!(out.contains(expected_text), "{duration}: {out}");
5336            assert_eq!(pl.computed_version(), expected_version, "{duration}: {out}");
5337            // The invariant, stated directly: a rendered decimal point and a
5338            // row-3 requirement are the same thing.
5339            assert_eq!(
5340                out.contains(expected_text) && expected_text.contains('.'),
5341                expected_version == Some(3),
5342                "{duration}: rendered text and §8 row 3 must agree: {out}"
5343            );
5344            // Bit-exact round-trip of the duration itself.
5345            let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
5346            assert_eq!(reparsed.segments[0].duration.get(), duration, "{out}");
5347        }
5348    }
5349
5350    /// Regression (issue #872): durations finer than 1 ms must survive
5351    /// rendering. `to_m3u8` used a hardcoded `{:.3}` for `#EXTINF` and
5352    /// integer-millisecond math for every other seconds value, so real
5353    /// content silently lost precision — Apple's BipBop playlists carry
5354    /// `#EXTINF:9.9766` (rendered back as `9.977`) and RFC 8216bis §9.11's
5355    /// LL-HLS example carries `DURATION=2.00004` (rendered back as `2`).
5356    /// Found by round-tripping the real `fixtures/hls/real/` playlists; no
5357    /// hand-made 3-decimal fixture could have surfaced it.
5358    #[test]
5359    fn sub_millisecond_durations_survive_rendering() {
5360        // #EXTINF — the value that actually failed against real Apple data.
5361        let pl = playlist(vec![seg("main.ts", 9.9766)]);
5362        let out = pl.to_m3u8().unwrap();
5363        assert!(
5364            out.contains("#EXTINF:9.9766,\n"),
5365            "EXTINF must not be truncated to 3 decimals:\n{out}"
5366        );
5367        assert_eq!(
5368            MediaPlaylist::parse(&out).unwrap().segments[0].duration,
5369            DecimalSeconds::new(9.9766).unwrap(),
5370            "duration must survive a round trip bit-exactly"
5371        );
5372
5373        // The ms-granular common case keeps its historical compact form.
5374        assert_eq!(format_secs(DecimalSeconds::new(0.334).unwrap()), "0.334");
5375        assert_eq!(format_secs(DecimalSeconds::new(1.5).unwrap()), "1.5");
5376        assert_eq!(format_secs(DecimalSeconds::new(6.0).unwrap()), "6");
5377        // ...and the sub-ms case is now lossless rather than rounded to it.
5378        assert_eq!(
5379            format_secs(DecimalSeconds::new(2.00004).unwrap()),
5380            "2.00004"
5381        );
5382        assert_eq!(
5383            format_secs(DecimalSeconds::new(4.00008).unwrap()),
5384            "4.00008"
5385        );
5386        assert_eq!(
5387            format_signed_secs(SignedDecimalSeconds::new(-10.5).unwrap()),
5388            "-10.5"
5389        );
5390        assert_eq!(
5391            format_signed_secs(SignedDecimalSeconds::new(-2.00004).unwrap()),
5392            "-2.00004"
5393        );
5394    }
5395
5396    /// Audit BH-W4 (issue #1140): `f64::from_str` is far more permissive
5397    /// than RFC 8216bis §4.2's decimal-floating-point grammar — it also
5398    /// accepts `nan`/`inf`/`infinity`, a leading `+`, and exponent
5399    /// notation. `#EXTINF:nan,` previously parsed `Ok` into a NaN duration.
5400    #[test]
5401    fn extinf_and_ll_hls_durations_reject_non_conformant_decimal_tokens() {
5402        for bad in ["nan", "inf", "-inf", "infinity", "1e10", "+3", "-5", ""] {
5403            let pl = format!("#EXTM3U\n#EXTINF:{bad},\nmain.ts\n");
5404            assert!(
5405                MediaPlaylist::parse(&pl).is_err(),
5406                "EXTINF:{bad} must be rejected"
5407            );
5408        }
5409        // TIME-OFFSET is the one signed field; it still rejects nan/inf/exponent.
5410        for bad in ["nan", "inf", "1e5"] {
5411            let pl = format!("#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-START:TIME-OFFSET={bad}\n");
5412            assert!(
5413                MediaPlaylist::parse(&pl).is_err(),
5414                "TIME-OFFSET={bad} must be rejected"
5415            );
5416        }
5417        // A conformant negative TIME-OFFSET is still accepted.
5418        let pl = "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-START:TIME-OFFSET=-12.5\n";
5419        assert_eq!(
5420            MediaPlaylist::parse(pl).unwrap().start.unwrap().time_offset,
5421            SignedDecimalSeconds::new(-12.5).unwrap()
5422        );
5423    }
5424
5425    /// Coordinator follow-up to BH-W4/T12 (issue #1140): a NaN/inf/negative
5426    /// value must never *exist* as a `DecimalSeconds` in the first place —
5427    /// no silent renderer-side clamp, exactly the same discipline as
5428    /// `AttrValue`. `DecimalSeconds::new` rejects it at construction, so a
5429    /// bad value can never reach `format_secs`/`format_extinf` at all.
5430    #[test]
5431    fn decimal_seconds_rejects_non_finite_and_negative() {
5432        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
5433            assert!(
5434                DecimalSeconds::new(bad).is_err(),
5435                "DecimalSeconds::new({bad}) must be rejected"
5436            );
5437        }
5438        // A finite, non-negative value round-trips through get() unchanged.
5439        assert_eq!(DecimalSeconds::new(6.0).unwrap().get(), 6.0);
5440    }
5441
5442    /// As above, for the signed field (`TIME-OFFSET`): only NaN/inf are
5443    /// rejected, since a negative value is legitimate there.
5444    #[test]
5445    fn signed_decimal_seconds_rejects_non_finite_but_allows_negative() {
5446        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5447            assert!(
5448                SignedDecimalSeconds::new(bad).is_err(),
5449                "SignedDecimalSeconds::new({bad}) must be rejected"
5450            );
5451        }
5452        assert_eq!(SignedDecimalSeconds::new(-6.0).unwrap().get(), -6.0);
5453    }
5454
5455    /// A whole real-shaped LL-HLS playlist built from RFC 8216bis §9.11's
5456    /// actual sub-millisecond part/segment durations must round-trip. The
5457    /// spec's own §9.11 fixture can't cover this (it is unparsable — its
5458    /// `...` elision line, see `tests/it/hls_fixture_corpus.rs`), so the values
5459    /// are exercised here instead.
5460    #[test]
5461    fn round_trip_rfc_9_11_sub_millisecond_part_durations() {
5462        let pl = MediaPlaylist {
5463            version: 9,
5464            target_duration: 4,
5465            segments: vec![MediaSegment {
5466                uri: "fileSequence271.mp4".into(),
5467                duration: DecimalSeconds::new(4.00008).unwrap(),
5468                parts: vec![
5469                    PartSpec {
5470                        uri: "filePart271.0.mp4".into(),
5471                        duration: DecimalSeconds::new(2.00004).unwrap(),
5472                        independent: true,
5473                        ..Default::default()
5474                    },
5475                    PartSpec {
5476                        uri: "filePart271.1.mp4".into(),
5477                        duration: DecimalSeconds::new(0.50001).unwrap(),
5478                        ..Default::default()
5479                    },
5480                ],
5481                ..Default::default()
5482            }],
5483            low_latency: Some(LowLatencyConfig {
5484                part_target: Some(DecimalSeconds::new(2.00002).unwrap()),
5485                part_hold_back: Some(DecimalSeconds::new(6.00006).unwrap()),
5486                ..Default::default()
5487            }),
5488            ..Default::default()
5489        };
5490        let text = pl.to_m3u8().unwrap();
5491        assert!(text.contains("#EXTINF:4.00008,"), "{text}");
5492        assert!(text.contains("DURATION=2.00004"), "{text}");
5493        assert!(text.contains("DURATION=0.50001"), "{text}");
5494        assert!(text.contains("PART-TARGET=2.00002"), "{text}");
5495        assert_eq!(
5496            MediaPlaylist::parse(&text).expect("must parse"),
5497            pl,
5498            "sub-ms LL-HLS durations must round-trip:\n{text}"
5499        );
5500    }
5501
5502    // --- discontinuity tag tests ---
5503
5504    #[test]
5505    fn discontinuity_tag_emitted_before_extinf() {
5506        // seg1 is discontinuous; the tag must appear before its #EXTINF.
5507        let pl = playlist(vec![
5508            seg("s0.m4s", 5.0),
5509            seg_disc("s1.m4s", 5.0),
5510            seg("s2.m4s", 5.0),
5511        ]);
5512        let out = pl.to_m3u8().unwrap();
5513        assert_eq!(out.matches("#EXT-X-DISCONTINUITY\n").count(), 1);
5514        // The tag must immediately precede the #EXTINF for s1.
5515        let disc_pos = out.find("#EXT-X-DISCONTINUITY\n").unwrap();
5516        let extinf_pos = out.find("#EXTINF:5.000,\n#s1.m4s\n").unwrap_or_else(|| {
5517            // Find the position of "s1.m4s" in the output and trace back to its #EXTINF.
5518            let s1_pos = out.find("s1.m4s\n").unwrap();
5519            // The #EXTINF line starts 11 chars before "5.000,\n" — find it preceding s1.
5520            out[..s1_pos].rfind("#EXTINF:").unwrap()
5521        });
5522        assert!(
5523            disc_pos < extinf_pos,
5524            "#EXT-X-DISCONTINUITY must appear before #EXTINF of s1"
5525        );
5526        // The discontinuity tag must be the line immediately before #EXTINF:
5527        let tag_end = disc_pos + "#EXT-X-DISCONTINUITY\n".len();
5528        assert!(
5529            out[tag_end..].starts_with("#EXTINF:"),
5530            "#EXT-X-DISCONTINUITY must be immediately before #EXTINF, got: {:?}",
5531            &out[tag_end..tag_end + 20]
5532        );
5533    }
5534
5535    #[test]
5536    fn no_discontinuity_tag_when_all_continuous() {
5537        let pl = playlist(vec![
5538            seg("s0.m4s", 5.0),
5539            seg("s1.m4s", 5.0),
5540            seg("s2.m4s", 5.0),
5541        ]);
5542        let out = pl.to_m3u8().unwrap();
5543        assert!(
5544            !out.contains("#EXT-X-DISCONTINUITY\n"),
5545            "no tag when all segments are continuous"
5546        );
5547    }
5548
5549    #[test]
5550    fn discontinuity_sequence_emitted_when_nonzero() {
5551        let pl = MediaPlaylist {
5552            version: 3,
5553            target_duration: 6,
5554            media_sequence: 5,
5555            discontinuity_sequence: 2,
5556            segments: vec![seg("s5.m4s", 6.0)],
5557            endlist: false,
5558            extra_tags: vec![],
5559            low_latency: None,
5560            iframes_only: false,
5561            open_segment: None,
5562            ..Default::default()
5563        };
5564        let out = pl.to_m3u8().unwrap();
5565        assert!(
5566            out.contains("#EXT-X-DISCONTINUITY-SEQUENCE:2\n"),
5567            "header must be present when n>0"
5568        );
5569    }
5570
5571    #[test]
5572    fn discontinuity_sequence_absent_when_zero() {
5573        let pl = playlist(vec![seg("s0.m4s", 6.0)]);
5574        let out = pl.to_m3u8().unwrap();
5575        assert!(
5576            !out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
5577            "header must be absent when n==0"
5578        );
5579    }
5580
5581    // --- LL-HLS render tests (issue #702: OpenSegment) ---
5582
5583    fn ll_config() -> LowLatencyConfig {
5584        LowLatencyConfig {
5585            part_target: Some(DecimalSeconds::new(0.5).unwrap()),
5586            part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
5587            preload_hint_part: None,
5588            ..Default::default()
5589        }
5590    }
5591
5592    #[test]
5593    fn server_control_without_part_target_keeps_part_hold_back() {
5594        let pl = MediaPlaylist {
5595            target_duration: 4,
5596            low_latency: Some(LowLatencyConfig {
5597                part_target: None,
5598                part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
5599                hold_back: Some(DecimalSeconds::new(12.0).unwrap()),
5600                ..Default::default()
5601            }),
5602            ..Default::default()
5603        };
5604        let out = pl.to_m3u8().unwrap();
5605        assert!(out.contains("PART-HOLD-BACK=1.5"), "{out}");
5606        assert!(!out.contains("#EXT-X-PART-INF"), "{out}");
5607    }
5608
5609    #[test]
5610    fn ll_hls_renders_server_control_part_inf_and_parts() {
5611        let pl = MediaPlaylist {
5612            version: 9,
5613            target_duration: 4,
5614            media_sequence: 0,
5615            discontinuity_sequence: 0,
5616            segments: vec![MediaSegment {
5617                uri: "seg-1-4.m4s".into(),
5618                duration: DecimalSeconds::new(4.0).unwrap(),
5619                discontinuous: false,
5620                parts: vec![PartSpec {
5621                    uri: "part-1-1.m4s".into(),
5622                    duration: DecimalSeconds::new(0.5).unwrap(),
5623                    independent: true,
5624                    ..Default::default()
5625                }],
5626                ..Default::default()
5627            }],
5628            endlist: false,
5629            extra_tags: vec![],
5630            low_latency: Some(ll_config()),
5631            iframes_only: false,
5632            open_segment: None,
5633            ..Default::default()
5634        };
5635        let out = pl.to_m3u8().unwrap();
5636        assert!(out.contains("#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES"));
5637        assert!(out.contains("#EXT-X-PART-INF:PART-TARGET="));
5638        assert!(out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.m4s\""));
5639    }
5640
5641    #[test]
5642    fn open_segment_renders_parts_without_extinf() {
5643        let pl = MediaPlaylist {
5644            version: 9,
5645            target_duration: 4,
5646            media_sequence: 0,
5647            discontinuity_sequence: 0,
5648            segments: vec![MediaSegment {
5649                uri: "seg-1-4.m4s".into(),
5650                duration: DecimalSeconds::new(4.0).unwrap(),
5651                discontinuous: false,
5652                parts: vec![],
5653                ..Default::default()
5654            }],
5655            endlist: false,
5656            extra_tags: vec![],
5657            low_latency: Some(ll_config()),
5658            iframes_only: false,
5659            open_segment: Some(OpenSegment::new(vec![PartSpec {
5660                uri: "part-1-5.0.m4s".into(),
5661                duration: DecimalSeconds::new(0.5).unwrap(),
5662                independent: true,
5663                ..Default::default()
5664            }])),
5665            ..Default::default()
5666        };
5667        let out = pl.to_m3u8().unwrap();
5668        // The open part is rendered as an #EXT-X-PART line.
5669        assert!(
5670            out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
5671            "open segment's part must render:\n{out}"
5672        );
5673        // The closed segment is still rendered with its #EXTINF (a whole
5674        // 4.0 s renders as the integer `4` — see
5675        // `integral_extinf_renders_as_an_integer_and_needs_no_version`).
5676        assert!(out.contains("#EXTINF:4,\n"), "{out}");
5677        assert!(out.contains("seg-1-4.m4s"));
5678        // The open part's URI never appears on an #EXTINF/plain-URI line — only
5679        // inside its #EXT-X-PART line (there is no #EXTINF for an open segment).
5680        assert!(
5681            !out.contains("#EXTINF:0.500,\npart-1-5.0.m4s"),
5682            "open segment must not be rendered as a closed #EXTINF segment:\n{out}"
5683        );
5684        // Exact count: only 1 closed segment, so exactly 1 #EXTINF occurrence.
5685        assert_eq!(
5686            out.matches("#EXTINF:").count(),
5687            1,
5688            "only closed segments should have #EXTINF lines; open segment must not:\n{out}"
5689        );
5690        let lines: Vec<&str> = out.lines().collect();
5691        for (i, line) in lines.iter().enumerate() {
5692            if *line == "part-1-5.0.m4s" {
5693                panic!("open part URI must not appear on its own URI line: {out}");
5694            }
5695            if line.starts_with("#EXTINF") && i + 1 < lines.len() {
5696                assert_ne!(
5697                    lines[i + 1],
5698                    "part-1-5.0.m4s",
5699                    "open part URI must not follow an #EXTINF line:\n{out}"
5700                );
5701            }
5702        }
5703    }
5704
5705    /// Audit BH-O3 (#1111): the open segment's EXT-X-MAP is rendered only
5706    /// with low-latency on, so it must not raise the computed version
5707    /// (EXT-X-MAP needs >= 6) when LL is off.
5708    #[test]
5709    fn open_segment_map_does_not_count_toward_version_without_low_latency() {
5710        let mut open = OpenSegment::new(vec![PartSpec {
5711            uri: "part-1-5.0.m4s".into(),
5712            duration: DecimalSeconds::new(0.5).unwrap(),
5713            independent: true,
5714            ..Default::default()
5715        }]);
5716        open.map = Some(MapTag {
5717            uri: "init.mp4".into(),
5718            byte_range: None,
5719            extra_attrs: Vec::new(),
5720        });
5721        let mut pl = MediaPlaylist {
5722            target_duration: 4,
5723            segments: vec![seg("seg-1-4.m4s", 4.0)],
5724            open_segment: Some(open),
5725            ..Default::default()
5726        };
5727        assert_eq!(
5728            pl.computed_version(),
5729            None,
5730            "map not rendered, no version row"
5731        );
5732        pl.low_latency = Some(ll_config());
5733        assert!(pl.computed_version() >= Some(6));
5734    }
5735
5736    #[test]
5737    fn open_segment_not_rendered_without_low_latency() {
5738        let pl = MediaPlaylist {
5739            version: 9,
5740            target_duration: 4,
5741            media_sequence: 0,
5742            discontinuity_sequence: 0,
5743            segments: vec![seg("seg-1-4.m4s", 4.0)],
5744            endlist: false,
5745            extra_tags: vec![],
5746            low_latency: None,
5747            iframes_only: false,
5748            open_segment: Some(OpenSegment::new(vec![PartSpec {
5749                uri: "part-1-5.0.m4s".into(),
5750                duration: DecimalSeconds::new(0.5).unwrap(),
5751                independent: true,
5752                ..Default::default()
5753            }])),
5754            ..Default::default()
5755        };
5756        let out = pl.to_m3u8().unwrap();
5757        assert!(
5758            !out.contains("part-1-5.0.m4s"),
5759            "open segment parts must not render without low_latency:\n{out}"
5760        );
5761        assert!(!out.contains("#EXT-X-PART:"));
5762    }
5763
5764    #[test]
5765    fn preload_hint_rendered_from_low_latency() {
5766        let mut ll = ll_config();
5767        ll.preload_hint_part = Some("part-1-5.1.m4s".into());
5768        let pl = MediaPlaylist {
5769            version: 9,
5770            target_duration: 4,
5771            media_sequence: 0,
5772            discontinuity_sequence: 0,
5773            segments: vec![seg("seg-1-4.m4s", 4.0)],
5774            endlist: false,
5775            extra_tags: vec![],
5776            low_latency: Some(ll),
5777            iframes_only: false,
5778            open_segment: None,
5779            ..Default::default()
5780        };
5781        let out = pl.to_m3u8().unwrap();
5782        assert!(out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""));
5783    }
5784
5785    #[test]
5786    fn open_segment_parts_precede_preload_hint() {
5787        let mut ll = ll_config();
5788        ll.preload_hint_part = Some("part-1-5.1.m4s".into());
5789        let pl = MediaPlaylist {
5790            version: 9,
5791            target_duration: 4,
5792            media_sequence: 0,
5793            discontinuity_sequence: 0,
5794            segments: vec![MediaSegment {
5795                uri: "seg-1-4.m4s".into(),
5796                duration: DecimalSeconds::new(4.0).unwrap(),
5797                discontinuous: false,
5798                parts: vec![],
5799                ..Default::default()
5800            }],
5801            endlist: false,
5802            extra_tags: vec![],
5803            low_latency: Some(ll),
5804            iframes_only: false,
5805            open_segment: Some(OpenSegment::new(vec![PartSpec {
5806                uri: "part-1-5.0.m4s".into(),
5807                duration: DecimalSeconds::new(0.5).unwrap(),
5808                independent: true,
5809                ..Default::default()
5810            }])),
5811            ..Default::default()
5812        };
5813        let out = pl.to_m3u8().unwrap();
5814        // Both the open-segment part and preload-hint must be present.
5815        assert!(
5816            out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
5817            "open-segment part must be present:\n{out}"
5818        );
5819        assert!(
5820            out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""),
5821            "preload-hint must be present:\n{out}"
5822        );
5823        // The open-segment #EXT-X-PART line must appear BEFORE the #EXT-X-PRELOAD-HINT line.
5824        let part_pos = out
5825            .find("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES")
5826            .expect("open-segment part line not found");
5827        let preload_pos = out
5828            .find("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\"")
5829            .expect("preload-hint line not found");
5830        assert!(
5831            part_pos < preload_pos,
5832            "open-segment #EXT-X-PART must precede #EXT-X-PRELOAD-HINT:\npart at {}, preload at {}\noutput:\n{out}",
5833            part_pos,
5834            preload_pos
5835        );
5836    }
5837
5838    /// Issue #717 slice 5 follow-up fix: `#EXT-X-MAP` applies "until the
5839    /// next `EXT-X-MAP` tag or the end of the Playlist" (RFC 8216bis
5840    /// §4.4.4.5) — including to the *open* (never-yet-closed) segment, even
5841    /// when NO segment has closed yet (the very first segment of a
5842    /// freshly-tuned-into live stream). Before this fix, `OpenSegment`
5843    /// carried no `map` field at all, so a client parsing a playlist with
5844    /// only an open segment had no way to learn the init segment's URI
5845    /// until that segment's first *closed* appearance — needlessly
5846    /// delaying every part's playback until then.
5847    #[test]
5848    fn open_segment_inherits_map_when_no_segment_has_closed_yet() {
5849        let text = "#EXTM3U\n\
5850#EXT-X-VERSION:9\n\
5851#EXT-X-TARGETDURATION:4\n\
5852#EXT-X-MEDIA-SEQUENCE:1\n\
5853#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
5854#EXT-X-PART-INF:PART-TARGET=0.5\n\
5855#EXT-X-MAP:URI=\"init-1.mp4\"\n\
5856#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.0.m4s\",INDEPENDENT=YES\n\
5857#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-1.1.m4s\"\n";
5858        let pl = MediaPlaylist::parse(text).expect("must parse");
5859        assert!(pl.segments.is_empty(), "no segment has closed yet");
5860        let open = pl.open_segment.as_ref().expect("one open segment");
5861        assert_eq!(
5862            open.map,
5863            Some(MapTag {
5864                uri: "init-1.mp4".into(),
5865                byte_range: None,
5866                extra_attrs: Vec::new(),
5867            }),
5868            "the open segment must inherit the EXT-X-MAP that precedes it, \
5869             even though no segment has closed yet"
5870        );
5871    }
5872
5873    /// Round trip the same no-closed-segments-yet shape through
5874    /// `to_m3u8`/`parse`, proving the renderer emits the `#EXT-X-MAP` line
5875    /// for a bare open segment (not just for closed ones).
5876    #[test]
5877    fn round_trip_open_segment_only_with_map() {
5878        let pl = MediaPlaylist {
5879            version: 9,
5880            target_duration: 4,
5881            media_sequence: 1,
5882            discontinuity_sequence: 0,
5883            segments: vec![],
5884            open_segment: Some(
5885                OpenSegment::new(vec![PartSpec {
5886                    uri: "part-1-1.0.m4s".into(),
5887                    duration: DecimalSeconds::new(0.5).unwrap(),
5888                    independent: true,
5889                    ..Default::default()
5890                }])
5891                .with_map(MapTag {
5892                    uri: "init-1.mp4".into(),
5893                    byte_range: None,
5894                    extra_attrs: Vec::new(),
5895                }),
5896            ),
5897            endlist: false,
5898            extra_tags: vec![],
5899            low_latency: Some(ll_config()),
5900            iframes_only: false,
5901            ..Default::default()
5902        };
5903        let text = pl.to_m3u8().unwrap();
5904        assert!(
5905            text.contains("#EXT-X-MAP:URI=\"init-1.mp4\""),
5906            "renderer must emit EXT-X-MAP for a bare open segment:\n{text}"
5907        );
5908        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
5909        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
5910    }
5911
5912    // --- parsing (issue #717 slice 1): round-trip + real-world-sample tests ---
5913
5914    fn ll_config_full() -> LowLatencyConfig {
5915        LowLatencyConfig {
5916            part_target: Some(DecimalSeconds::new(0.5).unwrap()),
5917            part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()), // already at the 3x floor: idempotent through render.
5918            preload_hint_part: Some("part-9.2.m4s".into()),
5919            preload_hint_type: PreloadHintType::Part,
5920            preload_hint_byte_range_start: Some(0),
5921            preload_hint_byte_range_length: Some(1000),
5922            can_skip_until: Some(DecimalSeconds::new(24.0).unwrap()),
5923            can_block_reload: true,
5924            hold_back: None,
5925            can_skip_dateranges: false,
5926            ..Default::default()
5927        }
5928    }
5929
5930    #[test]
5931    fn round_trip_live_ll_playlist_with_parts_preload_and_server_control() {
5932        let map = MapTag {
5933            uri: "init.mp4".into(),
5934            byte_range: Some(ByteRange {
5935                length: 800,
5936                offset: Some(0),
5937            }),
5938            extra_attrs: Vec::new(),
5939        };
5940        let pl = MediaPlaylist {
5941            version: 9,
5942            target_duration: 4,
5943            media_sequence: 100,
5944            discontinuity_sequence: 0,
5945            segments: vec![MediaSegment {
5946                uri: "seg-9.m4s".into(),
5947                duration: DecimalSeconds::new(4.0).unwrap(),
5948                discontinuous: false,
5949                parts: vec![
5950                    PartSpec {
5951                        uri: "part-9.0.m4s".into(),
5952                        duration: DecimalSeconds::new(0.5).unwrap(),
5953                        independent: true,
5954                        byte_range: None,
5955                        gap: false,
5956                        ..Default::default()
5957                    },
5958                    PartSpec {
5959                        uri: "part-9.1.m4s".into(),
5960                        duration: DecimalSeconds::new(0.5).unwrap(),
5961                        independent: false,
5962                        byte_range: Some(ByteRange {
5963                            length: 500,
5964                            offset: Some(1000),
5965                        }),
5966                        gap: false,
5967                        ..Default::default()
5968                    },
5969                ],
5970                byte_range: None,
5971                map: Some(map.clone()),
5972                ..Default::default()
5973            }],
5974            // `#EXT-X-MAP` applies "until the next EXT-X-MAP or the end of
5975            // the Playlist" (RFC 8216bis §4.4.4.5) — the open segment is
5976            // still governed by the same map as the preceding closed
5977            // segment (no new `#EXT-X-MAP` appears between them), so it
5978            // must carry it too for a lossless round trip.
5979            open_segment: Some(
5980                OpenSegment::new(vec![PartSpec {
5981                    uri: "part-10.0.m4s".into(),
5982                    duration: DecimalSeconds::new(0.5).unwrap(),
5983                    independent: true,
5984                    byte_range: None,
5985                    gap: true,
5986                    extra_attrs: Vec::new(),
5987                }])
5988                .with_map(map.clone()),
5989            ),
5990            endlist: false,
5991            extra_tags: vec![],
5992            low_latency: Some(ll_config_full()),
5993            iframes_only: false,
5994            rendition_reports: vec![RenditionReport {
5995                uri: "../audio/playlist.m3u8".into(),
5996                last_msn: 100,
5997                last_part: Some(1),
5998                extra_attrs: Vec::new(),
5999            }],
6000            skip: None,
6001            ..Default::default()
6002        };
6003        let text = pl.to_m3u8().unwrap();
6004        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6005        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6006    }
6007
6008    #[test]
6009    fn round_trip_vod_playlist_with_byteranges_map_and_endlist() {
6010        let map = MapTag {
6011            uri: "init.mp4".into(),
6012            byte_range: None,
6013            extra_attrs: Vec::new(),
6014        };
6015        let pl = MediaPlaylist {
6016            version: 6,
6017            target_duration: 10,
6018            media_sequence: 0,
6019            discontinuity_sequence: 0,
6020            segments: vec![
6021                MediaSegment {
6022                    uri: "media.ts".into(),
6023                    duration: DecimalSeconds::new(10.0).unwrap(),
6024                    discontinuous: false,
6025                    parts: vec![],
6026                    byte_range: Some(ByteRange {
6027                        length: 500_000,
6028                        offset: Some(0),
6029                    }),
6030                    map: Some(map.clone()),
6031                    ..Default::default()
6032                },
6033                MediaSegment {
6034                    uri: "media.ts".into(),
6035                    duration: DecimalSeconds::new(10.0).unwrap(),
6036                    discontinuous: false,
6037                    parts: vec![],
6038                    // No offset: continues immediately after the previous
6039                    // sub-range of the same resource (RFC 8216bis §4.4.4.2).
6040                    byte_range: Some(ByteRange {
6041                        length: 500_000,
6042                        offset: None,
6043                    }),
6044                    // Same map as the previous segment — to_m3u8 must dedup
6045                    // (emit the tag only once) and parse must carry it forward.
6046                    map: Some(map.clone()),
6047                    pre_tags: vec![
6048                        "#EXT-X-DATERANGE:ID=\"ad-1\",START-DATE=\"2024-01-01T00:00:00.000Z\",DURATION=15.0"
6049                            .into(),
6050                    ],
6051                    ..Default::default()
6052                },
6053            ],
6054            open_segment: None,
6055            endlist: true,
6056            low_latency: None,
6057            iframes_only: false,
6058            rendition_reports: vec![],
6059            skip: None,
6060            ..Default::default()
6061        };
6062        let text = pl.to_m3u8().unwrap();
6063        // The map is only emitted once (dedup), not once per segment.
6064        assert_eq!(
6065            text.matches("#EXT-X-MAP:").count(),
6066            1,
6067            "identical map on consecutive segments must render once:\n{text}"
6068        );
6069        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6070        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6071    }
6072
6073    /// Round-trips the remaining new §4.4.2/§4.4.3.5/§4.4.4.7/§4.4.4.8 tags
6074    /// that live on [`MediaPlaylist`] (issue #872): INDEPENDENT-SEGMENTS,
6075    /// START, DEFINE (IMPORT form — only valid in a Media Playlist),
6076    /// PLAYLIST-TYPE, GAP, and BITRATE (carry-forward + dedup, like MAP).
6077    #[test]
6078    fn round_trip_media_playlist_with_new_872_tags() {
6079        let pl = MediaPlaylist {
6080            version: 6,
6081            target_duration: 10,
6082            media_sequence: 0,
6083            discontinuity_sequence: 0,
6084            independent_segments: true,
6085            start: Some(StartPoint {
6086                time_offset: SignedDecimalSeconds::new(5.5).unwrap(),
6087                precise: false,
6088                extra_attrs: Vec::new(),
6089            }),
6090            defines: vec![Define::Import {
6091                name: "base".into(),
6092                extra_attrs: Vec::new(),
6093            }],
6094            playlist_type: Some(PlaylistType::Vod),
6095            segments: vec![
6096                MediaSegment {
6097                    uri: "seg0.ts".into(),
6098                    duration: DecimalSeconds::new(10.0).unwrap(),
6099                    bitrate: Some(2000),
6100                    ..Default::default()
6101                },
6102                MediaSegment {
6103                    uri: "seg1.ts".into(),
6104                    duration: DecimalSeconds::new(10.0).unwrap(),
6105                    gap: true,
6106                    bitrate: Some(2000),
6107                    ..Default::default()
6108                },
6109                MediaSegment {
6110                    uri: "seg2.ts".into(),
6111                    duration: DecimalSeconds::new(10.0).unwrap(),
6112                    bitrate: Some(1800),
6113                    ..Default::default()
6114                },
6115            ],
6116            endlist: true,
6117            ..Default::default()
6118        };
6119        let text = pl.to_m3u8().unwrap();
6120        assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
6121        assert!(text.contains("#EXT-X-DEFINE:IMPORT=\"base\"\n"));
6122        assert!(text.contains("#EXT-X-START:TIME-OFFSET=5.5\n"));
6123        assert!(
6124            !text.contains("PRECISE"),
6125            "PRECISE=NO must be omitted:\n{text}"
6126        );
6127        assert!(text.contains("#EXT-X-PLAYLIST-TYPE:VOD\n"));
6128        assert!(text.contains("#EXT-X-GAP\n"));
6129        // BITRATE carries forward + dedups: 2000 (seg0), unchanged for seg1
6130        // (no re-emit), then 1800 for seg2 — exactly 2 EXT-X-BITRATE lines.
6131        assert_eq!(
6132            text.matches("#EXT-X-BITRATE:").count(),
6133            2,
6134            "unchanged bitrate must not re-emit:\n{text}"
6135        );
6136        assert!(text.contains("#EXT-X-BITRATE:2000\n"));
6137        assert!(text.contains("#EXT-X-BITRATE:1800\n"));
6138        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6139        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6140    }
6141
6142    /// `#EXT-X-PLAYLIST-TYPE` with an unrecognized value must error rather
6143    /// than silently default (issue #872): unlike some other attributes,
6144    /// there is no spec-sanctioned fallback for a garbage mutability token.
6145    #[test]
6146    fn parse_rejects_unrecognized_playlist_type() {
6147        let text = "#EXTM3U\n\
6148#EXT-X-VERSION:3\n\
6149#EXT-X-TARGETDURATION:6\n\
6150#EXT-X-PLAYLIST-TYPE:LIVE\n\
6151#EXTINF:6.000,\n\
6152s0.m4s\n";
6153        let err = MediaPlaylist::parse(text).expect_err("unrecognized PLAYLIST-TYPE must error");
6154        assert!(matches!(err, Error::HlsParse { .. }));
6155    }
6156
6157    /// `#EXT-X-DEFINE` with zero or more than one of NAME/IMPORT/QUERYPARAM
6158    /// must error (RFC 8216bis §4.4.2.3, issue #872).
6159    #[test]
6160    fn parse_rejects_define_with_wrong_attribute_count() {
6161        let none = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-DEFINE:VALUE=\"x\"\n\
6162#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
6163        let err = MediaPlaylist::parse(none).expect_err("DEFINE with none of the three must error");
6164        assert!(matches!(err, Error::HlsParse { .. }));
6165
6166        let both = "#EXTM3U\n#EXT-X-VERSION:3\n\
6167#EXT-X-DEFINE:NAME=\"a\",VALUE=\"1\",IMPORT=\"b\"\n\
6168#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
6169        let err = MediaPlaylist::parse(both).expect_err("DEFINE with two of the three must error");
6170        assert!(matches!(err, Error::HlsParse { .. }));
6171    }
6172
6173    /// `#EXT-X-SESSION-DATA` requires exactly one of VALUE/URI (issue #872).
6174    #[test]
6175    fn parse_rejects_session_data_with_wrong_content_count() {
6176        let neither = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\"\n";
6177        let err = MasterPlaylist::parse(neither)
6178            .expect_err("SESSION-DATA with neither VALUE nor URI must error");
6179        assert!(matches!(err, Error::HlsParse { .. }));
6180
6181        let both = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\",VALUE=\"v\",URI=\"u\"\n";
6182        let err = MasterPlaylist::parse(both)
6183            .expect_err("SESSION-DATA with both VALUE and URI must error");
6184        assert!(matches!(err, Error::HlsParse { .. }));
6185    }
6186
6187    /// `#EXT-X-SESSION-KEY`'s `IV` must be exactly 32 hex digits (issue #872).
6188    #[test]
6189    fn parse_rejects_malformed_session_key_iv() {
6190        let text = "#EXTM3U\n\
6191#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"k\",IV=0xnotahexvalue\n";
6192        let err = MasterPlaylist::parse(text).expect_err("malformed IV must error");
6193        assert!(matches!(err, Error::InvalidIv { value } if value == "0xnotahexvalue"));
6194    }
6195
6196    #[test]
6197    fn round_trip_multivariant_playlist() {
6198        let pl = MasterPlaylist {
6199            version: 7,
6200            media: vec![],
6201            variants: vec![
6202                Variant {
6203                    bandwidth: 300_000,
6204                    average_bandwidth: None,
6205                    frame_rate: None,
6206                    codecs: Some("avc1.64001e,mp4a.40.2".into()),
6207                    resolution: Some((640, 360)),
6208                    uri: "v300/index.m3u8".into(),
6209                    ..Default::default()
6210                },
6211                Variant {
6212                    bandwidth: 800_000,
6213                    average_bandwidth: None,
6214                    frame_rate: None,
6215                    codecs: Some("avc1.640028,mp4a.40.2".into()),
6216                    resolution: Some((1280, 720)),
6217                    uri: "v800/index.m3u8".into(),
6218                    ..Default::default()
6219                },
6220            ],
6221            iframe_variants: vec![IFrameVariant {
6222                bandwidth: 50_000,
6223                average_bandwidth: None,
6224                codecs: Some("avc1.64001e".into()),
6225                resolution: Some((640, 360)),
6226                uri: "v300/iframe.m3u8".into(),
6227                extra_attrs: Vec::new(),
6228            }],
6229            ..Default::default()
6230        };
6231        let text = pl.to_m3u8().unwrap();
6232        let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
6233        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6234    }
6235
6236    /// Round-trips all 6 new §4.4.2/§4.4.6 Multivariant Playlist tags
6237    /// together (issue #872): INDEPENDENT-SEGMENTS, START, DEFINE (NAME/
6238    /// VALUE + QUERYPARAM forms), SESSION-DATA (VALUE + URI/FORMAT forms),
6239    /// SESSION-KEY, CONTENT-STEERING.
6240    #[test]
6241    fn round_trip_multivariant_playlist_with_new_872_tags() {
6242        // version 11 is not arbitrary: the QUERYPARAM `EXT-X-DEFINE` below
6243        // triggers §8 row 11, so `computed_version()` derives 11 and
6244        // `to_m3u8` renders it. Setting the floor to anything lower would
6245        // still render 11 (issue #880's floor semantics: raised, never
6246        // lowered), which would then re-parse to 11 and break the identity
6247        // round trip below — so the floor must match the derived minimum.
6248        let pl = MasterPlaylist {
6249            version: 11,
6250            independent_segments: true,
6251            start: Some(StartPoint {
6252                time_offset: SignedDecimalSeconds::new(-10.5).unwrap(),
6253                precise: true,
6254                extra_attrs: Vec::new(),
6255            }),
6256            defines: vec![
6257                Define::Name {
6258                    name: "base".into(),
6259                    value: "https://cdn.example.com/video12".into(),
6260                    extra_attrs: Vec::new(),
6261                },
6262                Define::QueryParam {
6263                    name: "token".into(),
6264                    extra_attrs: Vec::new(),
6265                },
6266            ],
6267            session_data: vec![
6268                SessionData {
6269                    data_id: "com.example.lyrics".into(),
6270                    content: SessionDataContent::Uri {
6271                        uri: "lyrics.json".into(),
6272                        format: SessionDataFormat::Json,
6273                    },
6274                    language: None,
6275                    extra_attrs: Vec::new(),
6276                },
6277                SessionData {
6278                    data_id: "com.example.title".into(),
6279                    content: SessionDataContent::Value("This is an example".into()),
6280                    language: Some("en".into()),
6281                    extra_attrs: Vec::new(),
6282                },
6283            ],
6284            session_keys: vec![
6285                SessionKey {
6286                    method: EncryptionMethod::Aes128,
6287                    uri: Some("https://priv.example.com/key.php?r=52".into()),
6288                    iv: None,
6289                    keyformat: Some("identity".into()),
6290                    keyformatversions: Some("1".into()),
6291                    extra_attrs: Vec::new(),
6292                },
6293                SessionKey {
6294                    method: EncryptionMethod::SampleAesCtr,
6295                    uri: Some("skd://key2".into()),
6296                    iv: Some([
6297                        0x9c, 0x7d, 0xb8, 0x77, 0x85, 0x70, 0xd0, 0x5c, 0x3a, 0x5e, 0x3d, 0x2c,
6298                        0x8a, 0xe5, 0x5e, 0x46,
6299                    ]),
6300                    keyformat: None,
6301                    keyformatversions: None,
6302                    extra_attrs: Vec::new(),
6303                },
6304            ],
6305            content_steering: Some(ContentSteering {
6306                server_uri: "/steering?video=00012".into(),
6307                pathway_id: Some("CDN-A".into()),
6308                extra_attrs: Vec::new(),
6309            }),
6310            media: vec![],
6311            variants: vec![Variant {
6312                bandwidth: 1_280_000,
6313                average_bandwidth: None,
6314                frame_rate: None,
6315                codecs: Some("avc1.64001e,mp4a.40.2".into()),
6316                resolution: Some((640, 360)),
6317                uri: "low/index.m3u8".into(),
6318                audio: None,
6319                video: None,
6320                subtitles: None,
6321                closed_captions: None,
6322                extra_attrs: Vec::new(),
6323            }],
6324            iframe_variants: vec![],
6325            extra_tags: vec![],
6326        };
6327        assert_eq!(
6328            pl.computed_version(),
6329            Some(11),
6330            "EXT-X-DEFINE with QUERYPARAM must derive §8 row 11 from the \
6331             typed `defines` field (issue #872 + #880 integration)"
6332        );
6333        let text = pl.to_m3u8().unwrap();
6334        assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
6335        assert!(text.contains("#EXT-X-START:TIME-OFFSET=-10.5,PRECISE=YES\n"));
6336        assert!(
6337            text.contains(
6338                "#EXT-X-DEFINE:NAME=\"base\",VALUE=\"https://cdn.example.com/video12\"\n"
6339            )
6340        );
6341        assert!(text.contains("#EXT-X-DEFINE:QUERYPARAM=\"token\"\n"));
6342        assert!(
6343            text.contains(
6344                "#EXT-X-SESSION-DATA:DATA-ID=\"com.example.lyrics\",URI=\"lyrics.json\"\n"
6345            )
6346        );
6347        assert!(text.contains(
6348            "#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,URI=\"skd://key2\",IV=0x9c7db8778570d05c3a5e3d2c8ae55e46\n"
6349        ));
6350        assert!(text.contains(
6351            "#EXT-X-CONTENT-STEERING:SERVER-URI=\"/steering?video=00012\",PATHWAY-ID=\"CDN-A\"\n"
6352        ));
6353        let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
6354        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6355    }
6356
6357    /// Real-world sample: RFC 8216bis §9.11 "Low-Latency Playlist" appendix
6358    /// example, verbatim for the segment/part/discontinuity/preload-hint/
6359    /// rendition-report lines (only the elided `...` header lines were filled
6360    /// in with plausible values, since the spec elides them for brevity).
6361    #[test]
6362    fn real_world_sample_ll_playlist_from_rfc8216bis_appendix() {
6363        let text = "\
6364#EXTM3U
6365#EXT-X-VERSION:9
6366#EXT-X-TARGETDURATION:4
6367#EXT-X-MEDIA-SEQUENCE:266
6368#EXT-X-PART-INF:PART-TARGET=2.00002
6369#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=6.00006
6370#EXTINF:4.00008,
6371fileSequence268.mp4
6372#EXTINF:4.00008,
6373fileSequence269.mp4
6374#EXTINF:4.00008,
6375fileSequence270.mp4
6376#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart271.0.mp4\"
6377#EXT-X-PART:DURATION=2.00004,URI=\"filePart271.1.mp4\"
6378#EXTINF:4.00008,
6379fileSequence271.mp4
6380#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart272.0.mp4\"
6381#EXT-X-PART:DURATION=0.50001,URI=\"filePart272.1.mp4\"
6382#EXTINF:2.50005,
6383fileSequence272.mp4
6384#EXT-X-DISCONTINUITY
6385#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll273.0.mp4\"
6386#EXT-X-PART:DURATION=2.00004,URI=\"midRoll273.1.mp4\"
6387#EXTINF:4.00008,
6388midRoll273.mp4
6389#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll274.0.mp4\"
6390#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"midRoll274.1.mp4\"
6391#EXT-X-RENDITION-REPORT:URI=\"/1M/LL-HLS.m3u8\",LAST-MSN=274,LAST-PART=1
6392";
6393        let pl = MediaPlaylist::parse(text).expect("real-world LL sample must parse");
6394        assert_eq!(pl.version, 9);
6395        assert_eq!(pl.target_duration, 4);
6396        assert_eq!(pl.media_sequence, 266);
6397        // 5 closed segments: 268, 269, 270, 271, 272 + the discontinuous
6398        // midRoll273 = 6; midRoll274 has parts but never closes with an
6399        // EXTINF/URI, so it becomes the open (in-progress) segment.
6400        assert_eq!(pl.segments.len(), 6, "{:?}", pl.segments);
6401        assert_eq!(pl.segments[4].uri, "fileSequence272.mp4");
6402        assert_eq!(pl.segments[4].parts.len(), 2);
6403        assert!(pl.segments[4].parts[0].independent);
6404        assert!(!pl.segments[4].parts[1].independent);
6405        assert_eq!(pl.segments[5].uri, "midRoll273.mp4");
6406        assert!(
6407            pl.segments[5].discontinuous,
6408            "midRoll273 follows #EXT-X-DISCONTINUITY"
6409        );
6410        let open = pl.open_segment.as_ref().expect("midRoll274 is open");
6411        assert_eq!(open.parts.len(), 1);
6412        assert_eq!(open.parts[0].uri, "midRoll274.0.mp4");
6413        let ll = pl.low_latency.as_ref().expect("LL config must be present");
6414        assert_eq!(ll.preload_hint_part.as_deref(), Some("midRoll274.1.mp4"));
6415        assert!(
6416            ll.can_block_reload,
6417            "CAN-BLOCK-RELOAD=YES in the fixture must parse to true"
6418        );
6419        assert_eq!(pl.rendition_reports.len(), 1);
6420        assert_eq!(pl.rendition_reports[0].uri, "/1M/LL-HLS.m3u8");
6421        assert_eq!(pl.rendition_reports[0].last_msn, 274);
6422        assert_eq!(pl.rendition_reports[0].last_part, Some(1));
6423    }
6424
6425    /// Real-world-shaped sample: a Playlist Delta Update (`#EXT-X-SKIP`),
6426    /// hand-written per RFC 8216bis §4.4.5.2's confirmed attribute grammar
6427    /// (no full numeric example is given in the spec appendix for this tag).
6428    #[test]
6429    fn real_world_sample_delta_update_with_skip() {
6430        let text = "#EXTM3U\n\
6431#EXT-X-VERSION:9\n\
6432#EXT-X-TARGETDURATION:4\n\
6433#EXT-X-MEDIA-SEQUENCE:1000\n\
6434#EXT-X-PART-INF:PART-TARGET=0.5\n\
6435#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-UNTIL=24.0,PART-HOLD-BACK=1.5\n\
6436#EXT-X-SKIP:SKIPPED-SEGMENTS=996,RECENTLY-REMOVED-DATERANGES=\"ad-1\tad-2\"\n\
6437#EXTINF:4.00000,\n\
6438fileSequence1996.mp4\n\
6439#EXTINF:4.00000,\n\
6440fileSequence1997.mp4\n";
6441        let pl = MediaPlaylist::parse(text).expect("delta update sample must parse");
6442        assert_eq!(pl.media_sequence, 1000);
6443        assert_eq!(pl.segments.len(), 2);
6444        assert_eq!(pl.segments[0].uri, "fileSequence1996.mp4");
6445        let skip = pl.skip.as_ref().expect("EXT-X-SKIP must be captured");
6446        assert_eq!(skip.skipped_segments, 996);
6447        assert_eq!(skip.recently_removed_daterange_ids, vec!["ad-1", "ad-2"]);
6448        let ll = pl.low_latency.as_ref().expect("LL config must be present");
6449        assert_eq!(ll.can_skip_until, Some(DecimalSeconds::new(24.0).unwrap()));
6450        assert!(!pl.endlist);
6451    }
6452
6453    /// Audit T12: a RECENTLY-REMOVED-DATERANGES id with `"`, CR, LF or the
6454    /// tab separator must be rejected, not injected into the tag line.
6455    #[test]
6456    fn skip_recently_removed_ids_reject_injection() {
6457        let text = "#EXTM3U\n#EXT-X-VERSION:10\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:0\n\
6458#EXT-X-SKIP:SKIPPED-SEGMENTS=3,RECENTLY-REMOVED-DATERANGES=\"ad-1\\tad-2\"\n\
6459#EXTINF:4,\na.mp4\n"
6460            .replace("\\t", "\t");
6461        let good = MediaPlaylist::parse(&text).unwrap();
6462        // multi-id list round-trips byte-exact
6463        assert_eq!(good.to_m3u8().unwrap(), text);
6464        for bad in ["a\"b", "a\rb", "a\nb", "a\tb"] {
6465            let mut pl = good.clone();
6466            pl.skip.as_mut().unwrap().recently_removed_daterange_ids = vec![bad.to_string()];
6467            assert!(
6468                matches!(pl.to_m3u8(), Err(Error::InvalidQuotedString { .. })),
6469                "{bad:?}"
6470            );
6471        }
6472    }
6473
6474    /// Issue #717 slice 1 fix: an origin that advertises LL-HLS tags but
6475    /// explicitly declines blocking reload (`CAN-BLOCK-RELOAD=NO`) must
6476    /// parse to `can_block_reload == false` — a client inferring support
6477    /// from `low_latency.is_some()` alone would get this wrong.
6478    #[test]
6479    fn real_world_sample_can_block_reload_no_is_parsed_not_inferred() {
6480        let text = "#EXTM3U\n\
6481#EXT-X-VERSION:9\n\
6482#EXT-X-TARGETDURATION:4\n\
6483#EXT-X-MEDIA-SEQUENCE:0\n\
6484#EXT-X-PART-INF:PART-TARGET=0.5\n\
6485#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=NO,PART-HOLD-BACK=1.5\n\
6486#EXTINF:4.00000,\n\
6487seg0.mp4\n";
6488        let pl = MediaPlaylist::parse(text).expect("must parse");
6489        let ll = pl
6490            .low_latency
6491            .as_ref()
6492            .expect("LL config must be present (PART-INF seen)");
6493        assert!(
6494            !ll.can_block_reload,
6495            "CAN-BLOCK-RELOAD=NO must not be inferred as true just because low_latency is Some"
6496        );
6497    }
6498
6499    /// RFC 8216bis §4.4.3.8: an absent `CAN-BLOCK-RELOAD` attribute (or an
6500    /// entirely absent `#EXT-X-SERVER-CONTROL` tag) means the server does
6501    /// NOT support blocking reload — default `false`, distinct from
6502    /// [`LowLatencyConfig::default()`]'s convenience value of `true`.
6503    #[test]
6504    fn parse_defaults_can_block_reload_false_when_attribute_absent() {
6505        let text = "#EXTM3U\n\
6506#EXT-X-VERSION:9\n\
6507#EXT-X-TARGETDURATION:4\n\
6508#EXT-X-MEDIA-SEQUENCE:0\n\
6509#EXT-X-PART-INF:PART-TARGET=0.5\n\
6510#EXTINF:4.00000,\n\
6511seg0.mp4\n";
6512        let pl = MediaPlaylist::parse(text).expect("must parse");
6513        let ll = pl
6514            .low_latency
6515            .as_ref()
6516            .expect("LL config must be present (PART-INF seen)");
6517        assert!(
6518            !ll.can_block_reload,
6519            "absent CAN-BLOCK-RELOAD/SERVER-CONTROL must default to false, not true"
6520        );
6521    }
6522
6523    /// Round trip a `CAN-BLOCK-RELOAD=NO` config through `to_m3u8`/`parse`
6524    /// to prove the renderer emits the actual value (not a hardcoded YES).
6525    #[test]
6526    fn round_trip_can_block_reload_no() {
6527        let pl = MediaPlaylist {
6528            version: 9,
6529            target_duration: 4,
6530            media_sequence: 0,
6531            discontinuity_sequence: 0,
6532            segments: vec![MediaSegment {
6533                uri: "seg0.mp4".into(),
6534                duration: DecimalSeconds::new(4.0).unwrap(),
6535                ..Default::default()
6536            }],
6537            low_latency: Some(LowLatencyConfig {
6538                part_target: Some(DecimalSeconds::new(0.5).unwrap()),
6539                part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
6540                can_block_reload: false,
6541                ..Default::default()
6542            }),
6543            ..Default::default()
6544        };
6545        let text = pl.to_m3u8().unwrap();
6546        assert!(
6547            text.contains("CAN-BLOCK-RELOAD=NO"),
6548            "renderer must emit the actual value:\n{text}"
6549        );
6550        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
6551        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
6552    }
6553
6554    #[test]
6555    fn parse_ignores_unrecognized_tag_by_preserving_it_into_extra_tags() {
6556        let text = "#EXTM3U\n\
6557#EXT-X-VERSION:3\n\
6558#EXT-X-TARGETDURATION:6\n\
6559#EXT-X-MEDIA-SEQUENCE:0\n\
6560#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:00.000Z\n\
6561#EXTINF:6.000,\n\
6562s0.m4s\n\
6563#EXT-X-ENDLIST\n";
6564        let pl = MediaPlaylist::parse(text).expect("unrecognized tag must not error");
6565        // Audit BH-W5: `#EXT-X-PROGRAM-DATE-TIME` is segment-defining, so it
6566        // is preserved verbatim on the *following* segment's `pre_tags`
6567        // rather than the playlist-level block.
6568        assert!(
6569            pl.segments
6570                .first()
6571                .is_some_and(|s| s.program_date_time.is_some()),
6572            "EXT-X-PROGRAM-DATE-TIME must be preserved as a typed field, not dropped: {:?}",
6573            pl.segments.first().map(|s| s.program_date_time)
6574        );
6575    }
6576
6577    #[test]
6578    fn parse_rejects_missing_targetduration() {
6579        let text = "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:6.000,\ns0.m4s\n";
6580        let err = MediaPlaylist::parse(text).expect_err("missing TARGETDURATION must error");
6581        assert!(matches!(err, Error::HlsParse { .. }));
6582    }
6583
6584    #[test]
6585    fn parse_rejects_malformed_part_missing_duration() {
6586        let text = "#EXTM3U\n\
6587#EXT-X-VERSION:9\n\
6588#EXT-X-TARGETDURATION:4\n\
6589#EXT-X-PART-INF:PART-TARGET=0.5\n\
6590#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
6591#EXT-X-PART:URI=\"part.m4s\"\n\
6592#EXTINF:4.000,\n\
6593seg.m4s\n";
6594        let err = MediaPlaylist::parse(text).expect_err("EXT-X-PART without DURATION must error");
6595        let Error::HlsParse { reason, .. } = err else {
6596            panic!("expected Error::HlsParse, got {err:?}")
6597        };
6598        assert!(reason.contains("DURATION"), "{reason}");
6599    }
6600
6601    #[test]
6602    fn parse_rejects_variant_uri_with_no_preceding_stream_inf() {
6603        let text = "#EXTM3U\n#EXT-X-VERSION:6\nv300/index.m3u8\n";
6604        let err = MasterPlaylist::parse(text).expect_err("orphan variant URI must error");
6605        assert!(matches!(err, Error::HlsParse { .. }));
6606    }
6607
6608    #[test]
6609    fn parse_master_playlist_models_ext_x_media() {
6610        let text = "#EXTM3U\n\
6611#EXT-X-VERSION:7\n\
6612#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\",NAME=\"English\",DEFAULT=YES,AUTOSELECT=YES,URI=\"eng.m3u8\"\n\
6613#EXT-X-STREAM-INF:BANDWIDTH=300000,CODECS=\"avc1.64001e,mp4a.40.2\",AUDIO=\"aac\"\n\
6614v300/index.m3u8\n";
6615        let pl = MasterPlaylist::parse(text).expect("EXT-X-MEDIA must parse");
6616        assert_eq!(pl.variants.len(), 1);
6617        assert_eq!(pl.media.len(), 1);
6618        assert_eq!(pl.media[0].name, "English");
6619    }
6620
6621    // -----------------------------------------------------------------------
6622    // Protocol version derivation (RFC 8216bis §8, issue #871) — table-driven
6623    // per docs/version-compatibility.md, plus the five named regression
6624    // cases from that issue.
6625    // -----------------------------------------------------------------------
6626
6627    /// A minimal, otherwise-untriggering Media Playlist: integer-second
6628    /// duration, no map/byte-range/iframes-only/skip/extra_tags. Every
6629    /// table-driven test starts here and flips exactly one trigger.
6630    fn base_media_playlist() -> MediaPlaylist {
6631        MediaPlaylist {
6632            version: 0,
6633            target_duration: 6,
6634            media_sequence: 0,
6635            segments: vec![seg("s0.m4s", 6.0)],
6636            endlist: true,
6637            ..Default::default()
6638        }
6639    }
6640
6641    /// Extract the `#EXT-X-VERSION:<n>` value from rendered text, or `None`
6642    /// if no such line is present.
6643    fn rendered_version(m3u8: &str) -> Option<u8> {
6644        m3u8.lines()
6645            .find_map(|l| l.strip_prefix("#EXT-X-VERSION:"))
6646            .map(|v| v.parse::<u8>().expect("version must be a valid u8"))
6647    }
6648
6649    #[test]
6650    fn version_row1_no_trigger_omits_the_tag() {
6651        let out = base_media_playlist().to_m3u8().unwrap();
6652        assert_eq!(rendered_version(&out), None, "no trigger:\n{out}");
6653    }
6654
6655    #[test]
6656    fn version_row2_key_iv_triggers_v2() {
6657        let mut pl = base_media_playlist();
6658        pl.extra_tags = vec![
6659            "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",IV=0x00000000000000000000000000000001"
6660                .into(),
6661        ];
6662        let out = pl.to_m3u8().unwrap();
6663        assert_eq!(rendered_version(&out), Some(2), "{out}");
6664    }
6665
6666    #[test]
6667    fn version_row3_float_extinf_triggers_v3() {
6668        let mut pl = base_media_playlist();
6669        pl.segments = vec![seg("s0.m4s", 6.5)];
6670        let out = pl.to_m3u8().unwrap();
6671        assert_eq!(rendered_version(&out), Some(3), "{out}");
6672    }
6673
6674    #[test]
6675    fn version_row4_byterange_triggers_v4() {
6676        let mut pl = base_media_playlist();
6677        pl.segments[0].byte_range = Some(ByteRange {
6678            length: 1000,
6679            offset: Some(0),
6680        });
6681        let out = pl.to_m3u8().unwrap();
6682        assert_eq!(rendered_version(&out), Some(4), "{out}");
6683    }
6684
6685    #[test]
6686    fn version_row4_iframes_only_triggers_v4() {
6687        let mut pl = base_media_playlist();
6688        pl.iframes_only = true;
6689        let out = pl.to_m3u8().unwrap();
6690        assert_eq!(rendered_version(&out), Some(4), "{out}");
6691    }
6692
6693    #[test]
6694    fn version_row5_sample_aes_triggers_v5() {
6695        let mut pl = base_media_playlist();
6696        pl.extra_tags = vec!["#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"https://k\",KEYID=0x01".into()];
6697        let out = pl.to_m3u8().unwrap();
6698        assert_eq!(rendered_version(&out), Some(5), "{out}");
6699    }
6700
6701    #[test]
6702    fn version_row5_keyformat_triggers_v5() {
6703        let mut pl = base_media_playlist();
6704        pl.extra_tags = vec![
6705            "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",KEYFORMAT=\"com.example\",\
6706             KEYFORMATVERSIONS=\"1\""
6707                .into(),
6708        ];
6709        let out = pl.to_m3u8().unwrap();
6710        assert_eq!(rendered_version(&out), Some(5), "{out}");
6711    }
6712
6713    #[test]
6714    fn version_row5_map_with_iframes_only_triggers_v5_not_v6() {
6715        let mut pl = base_media_playlist();
6716        pl.iframes_only = true;
6717        pl.segments[0].map = Some(MapTag {
6718            uri: "init.mp4".into(),
6719            byte_range: None,
6720            extra_attrs: Vec::new(),
6721        });
6722        let out = pl.to_m3u8().unwrap();
6723        assert_eq!(rendered_version(&out), Some(5), "{out}");
6724    }
6725
6726    #[test]
6727    fn version_row6_map_without_iframes_only_triggers_v6() {
6728        let mut pl = base_media_playlist();
6729        pl.segments[0].map = Some(MapTag {
6730            uri: "init.mp4".into(),
6731            byte_range: None,
6732            extra_attrs: Vec::new(),
6733        });
6734        let out = pl.to_m3u8().unwrap();
6735        assert_eq!(rendered_version(&out), Some(6), "{out}");
6736    }
6737
6738    #[test]
6739    fn version_row7_media_service_instream_id_triggers_v7_multivariant_only() {
6740        let mut pl = MasterPlaylist {
6741            version: 0,
6742            media: vec![],
6743            variants: vec![Variant {
6744                bandwidth: 300_000,
6745                average_bandwidth: None,
6746                frame_rate: None,
6747                codecs: Some("avc1.64001e".into()),
6748                resolution: None,
6749                uri: "v300/index.m3u8".into(),
6750                audio: None,
6751                video: None,
6752                subtitles: None,
6753                closed_captions: None,
6754                extra_attrs: Vec::new(),
6755            }],
6756            iframe_variants: vec![],
6757            extra_tags: vec![],
6758            ..Default::default()
6759        };
6760        pl.extra_tags = vec![
6761            "#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID=\"cc\",NAME=\"CC1\",\
6762             INSTREAM-ID=\"SERVICE1\""
6763                .into(),
6764        ];
6765        let out = pl.to_m3u8().unwrap();
6766        assert_eq!(rendered_version(&out), Some(7), "{out}");
6767    }
6768
6769    #[test]
6770    fn version_row8_variable_substitution_triggers_v8() {
6771        let mut pl = base_media_playlist();
6772        pl.segments = vec![seg("seg-{$id}.m4s", 6.0)];
6773        let out = pl.to_m3u8().unwrap();
6774        assert_eq!(rendered_version(&out), Some(8), "{out}");
6775    }
6776
6777    #[test]
6778    fn version_row8_variable_substitution_triggers_v8_multivariant() {
6779        let pl = MasterPlaylist {
6780            version: 0,
6781            media: vec![],
6782            variants: vec![Variant {
6783                bandwidth: 300_000,
6784                average_bandwidth: None,
6785                frame_rate: None,
6786                codecs: Some("avc1.64001e".into()),
6787                resolution: None,
6788                uri: "{$base}/index.m3u8".into(),
6789                audio: None,
6790                video: None,
6791                subtitles: None,
6792                closed_captions: None,
6793                extra_attrs: Vec::new(),
6794            }],
6795            iframe_variants: vec![],
6796            extra_tags: vec![],
6797            ..Default::default()
6798        };
6799        let out = pl.to_m3u8().unwrap();
6800        assert_eq!(rendered_version(&out), Some(8), "{out}");
6801    }
6802
6803    #[test]
6804    fn version_row9_skip_triggers_v9() {
6805        let mut pl = base_media_playlist();
6806        pl.skip = Some(SkipInfo {
6807            skipped_segments: 5,
6808            recently_removed_daterange_ids: vec![],
6809            ..Default::default()
6810        });
6811        let out = pl.to_m3u8().unwrap();
6812        assert_eq!(rendered_version(&out), Some(9), "{out}");
6813    }
6814
6815    #[test]
6816    fn version_row10_skip_replacing_daterange_triggers_v10() {
6817        let mut pl = base_media_playlist();
6818        pl.skip = Some(SkipInfo {
6819            skipped_segments: 5,
6820            recently_removed_daterange_ids: vec!["ad-1".into()],
6821            ..Default::default()
6822        });
6823        let out = pl.to_m3u8().unwrap();
6824        assert_eq!(rendered_version(&out), Some(10), "{out}");
6825    }
6826
6827    #[test]
6828    fn version_row11_define_queryparam_triggers_v11() {
6829        let mut pl = base_media_playlist();
6830        pl.extra_tags = vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()];
6831        let out = pl.to_m3u8().unwrap();
6832        assert_eq!(rendered_version(&out), Some(11), "{out}");
6833    }
6834
6835    #[test]
6836    fn version_row11_define_queryparam_triggers_v11_multivariant() {
6837        let pl = MasterPlaylist {
6838            version: 0,
6839            media: vec![],
6840            variants: vec![Variant {
6841                bandwidth: 300_000,
6842                average_bandwidth: None,
6843                frame_rate: None,
6844                codecs: Some("avc1.64001e".into()),
6845                resolution: None,
6846                uri: "v300/index.m3u8".into(),
6847                ..Default::default()
6848            }],
6849            iframe_variants: vec![],
6850            extra_tags: vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()],
6851            ..Default::default()
6852        };
6853        let out = pl.to_m3u8().unwrap();
6854        assert_eq!(rendered_version(&out), Some(11), "{out}");
6855    }
6856
6857    #[test]
6858    fn version_row12_req_attribute_triggers_v12() {
6859        let mut pl = base_media_playlist();
6860        pl.extra_tags = vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()];
6861        let out = pl.to_m3u8().unwrap();
6862        assert_eq!(rendered_version(&out), Some(12), "{out}");
6863    }
6864
6865    /// Audit BH-W8 (issue #1111): the §8 row-12 `REQ-` scan must see the
6866    /// per-tag `SC`/`PI`/`PH` attribute lists, not a separate playlist-wide
6867    /// union that the renderer never read. A config built directly with a
6868    /// `REQ-` attribute in `sc_extra_attrs` used to render that attribute
6869    /// while leaving `EXT-X-VERSION` at 11 — an under-declaration the §8
6870    /// rule forbids (and the before/after difference is exactly this test:
6871    /// with the duplicate field present the scan missed it).
6872    #[test]
6873    fn version_row12_req_attribute_in_per_tag_lists_triggers_v12() {
6874        let mut pl = base_media_playlist();
6875        pl.low_latency = Some(LowLatencyConfig {
6876            sc_extra_attrs: vec![(
6877                "REQ-LATENCY".to_string(),
6878                AttrValue::bare("ultra-low").unwrap(),
6879            )],
6880            ..Default::default()
6881        });
6882        let out = pl.to_m3u8().unwrap();
6883        assert!(out.contains("REQ-LATENCY=ultra-low"), "{out}");
6884        assert_eq!(rendered_version(&out), Some(12), "{out}");
6885
6886        // Same for PART-INF and PRELOAD-HINT.
6887        for (list, name) in [("pi", "REQ-VIDEO"), ("ph", "REQ-INFO")] {
6888            let mut pl = base_media_playlist();
6889            let attr = vec![(name.to_string(), AttrValue::bare("1").unwrap())];
6890            let mut ll = LowLatencyConfig::default();
6891            match list {
6892                "pi" => ll.pi_extra_attrs = attr,
6893                _ => ll.ph_extra_attrs = attr,
6894            }
6895            pl.low_latency = Some(ll);
6896            let out = pl.to_m3u8().unwrap();
6897            assert_eq!(rendered_version(&out), Some(12), "{list}: {out}");
6898        }
6899    }
6900
6901    #[test]
6902    fn version_row12_req_attribute_triggers_v12_multivariant() {
6903        let pl = MasterPlaylist {
6904            version: 0,
6905            media: vec![],
6906            variants: vec![Variant {
6907                bandwidth: 300_000,
6908                average_bandwidth: None,
6909                frame_rate: None,
6910                codecs: Some("avc1.64001e".into()),
6911                resolution: None,
6912                uri: "v300/index.m3u8".into(),
6913                ..Default::default()
6914            }],
6915            iframe_variants: vec![],
6916            extra_tags: vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()],
6917            ..Default::default()
6918        };
6919        let out = pl.to_m3u8().unwrap();
6920        assert_eq!(rendered_version(&out), Some(12), "{out}");
6921    }
6922
6923    #[test]
6924    fn version_row13_media_instream_id_non_cc_triggers_v13_multivariant_only() {
6925        let pl = MasterPlaylist {
6926            version: 0,
6927            media: vec![],
6928            variants: vec![Variant {
6929                bandwidth: 300_000,
6930                average_bandwidth: None,
6931                frame_rate: None,
6932                codecs: Some("avc1.64001e".into()),
6933                resolution: None,
6934                uri: "v300/index.m3u8".into(),
6935                ..Default::default()
6936            }],
6937            iframe_variants: vec![],
6938            extra_tags: vec![
6939                "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"Eng\",INSTREAM-ID=\"CC1\"".into(),
6940            ],
6941            ..Default::default()
6942        };
6943        let out = pl.to_m3u8().unwrap();
6944        assert_eq!(rendered_version(&out), Some(13), "{out}");
6945    }
6946
6947    #[test]
6948    fn version_multivariant_no_trigger_omits_the_tag() {
6949        let pl = MasterPlaylist {
6950            version: 0,
6951            media: vec![],
6952            variants: vec![Variant {
6953                bandwidth: 300_000,
6954                average_bandwidth: None,
6955                frame_rate: None,
6956                codecs: Some("avc1.64001e".into()),
6957                resolution: None,
6958                uri: "v300/index.m3u8".into(),
6959                ..Default::default()
6960            }],
6961            iframe_variants: vec![],
6962            extra_tags: vec![],
6963            ..Default::default()
6964        };
6965        let out = pl.to_m3u8().unwrap();
6966        assert_eq!(rendered_version(&out), None, "{out}");
6967    }
6968
6969    // --- The five named regression cases from issue #871 ---
6970
6971    /// Case 1: the specific regression this issue exists for. Shaped like
6972    /// `hls-runtime`'s actual LL-HLS media playlist (fMP4 segments, an
6973    /// `EXT-X-MAP` conveyed via `extra_tags` exactly as that origin emits
6974    /// it, `low_latency` config, no `EXT-X-I-FRAMES-ONLY`, no `EXT-X-SKIP`)
6975    /// — must render **6**, never the old hardcoded **9**.
6976    #[test]
6977    fn named_case_1_fmp4_low_latency_renders_6_not_9() {
6978        let pl = MediaPlaylist {
6979            version: 0, // hls-runtime no longer supplies an explicit floor.
6980            target_duration: 4,
6981            media_sequence: 100,
6982            segments: vec![MediaSegment {
6983                uri: "seg-1-100.m4s".into(),
6984                duration: DecimalSeconds::new(4.0).unwrap(),
6985                ..Default::default()
6986            }],
6987            open_segment: Some(OpenSegment::new(vec![PartSpec {
6988                uri: "part-1-101.0.m4s".into(),
6989                duration: DecimalSeconds::new(0.5).unwrap(),
6990                independent: true,
6991                ..Default::default()
6992            }])),
6993            extra_tags: vec!["#EXT-X-MAP:URI=\"init-1.mp4\"".into()],
6994            low_latency: Some(LowLatencyConfig {
6995                part_target: Some(DecimalSeconds::new(0.5).unwrap()),
6996                part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
6997                ..Default::default()
6998            }),
6999            iframes_only: false,
7000            ..Default::default()
7001        };
7002        let out = pl.to_m3u8().unwrap();
7003        assert_eq!(
7004            rendered_version(&out),
7005            Some(6),
7006            "fMP4 + low-latency must render 6, not the old hardcoded 9:\n{out}"
7007        );
7008        assert!(!out.contains("#EXT-X-VERSION:9"), "{out}");
7009    }
7010
7011    /// Case 2: a classic MPEG-TS playlist (no fMP4/LL features at all) whose
7012    /// segment durations are genuine floating-point values renders **3**.
7013    #[test]
7014    fn named_case_2_classic_mpegts_float_extinf_renders_3() {
7015        let pl = MediaPlaylist {
7016            version: 0,
7017            target_duration: 10,
7018            media_sequence: 0,
7019            segments: vec![
7020                seg("seg0.ts", 9.009),
7021                seg("seg1.ts", 9.009),
7022                seg("seg2.ts", 3.003),
7023            ],
7024            endlist: true,
7025            ..Default::default()
7026        };
7027        let out = pl.to_m3u8().unwrap();
7028        assert_eq!(rendered_version(&out), Some(3), "{out}");
7029    }
7030
7031    /// Case 3: adding `EXT-X-SKIP` to an otherwise-untriggering playlist
7032    /// raises the rendered version to **9** automatically.
7033    #[test]
7034    fn named_case_3_adding_skip_raises_version_to_9() {
7035        let mut pl = base_media_playlist();
7036        assert_eq!(
7037            rendered_version(&pl.to_m3u8().unwrap()),
7038            None,
7039            "sanity: no trigger before adding EXT-X-SKIP"
7040        );
7041        pl.skip = Some(SkipInfo {
7042            skipped_segments: 3,
7043            recently_removed_daterange_ids: vec![],
7044            ..Default::default()
7045        });
7046        let out = pl.to_m3u8().unwrap();
7047        assert_eq!(rendered_version(&out), Some(9), "{out}");
7048    }
7049
7050    /// Case 4: a playlist triggering nothing renders NO `EXT-X-VERSION` tag
7051    /// at all (RFC 8216bis §8's opening rule).
7052    #[test]
7053    fn named_case_4_no_trigger_renders_no_version_tag() {
7054        let out = base_media_playlist().to_m3u8().unwrap();
7055        assert!(!out.contains("#EXT-X-VERSION"), "{out}");
7056    }
7057
7058    /// Case 5: `SAMPLE-AES` (the CBCS CENC key tag this crate's own
7059    /// `cenc_ext_x_key` produces) renders **5**.
7060    #[test]
7061    fn named_case_5_sample_aes_renders_5() {
7062        let tag = cenc_ext_x_key(CencScheme::Cbcs, &[0xab; 16], "https://k.example/key")
7063            .expect("valid key_uri")
7064            .expect("cbcs must emit an EXT-X-KEY tag");
7065        let mut pl = base_media_playlist();
7066        pl.extra_tags = vec![tag];
7067        let out = pl.to_m3u8().unwrap();
7068        assert_eq!(rendered_version(&out), Some(5), "{out}");
7069    }
7070
7071    // --- F1 + F4: HOLD-BACK and CAN-SKIP-DATERANGES attribute coverage ---
7072
7073    /// RFC 8216bis §4.4.3.8: `CAN-SKIP-DATERANGES=YES` with `CAN-SKIP-UNTIL`
7074    /// must survive parse -> serialize -> re-parse losslessly.
7075    #[test]
7076    fn parse_and_round_trip_can_skip_dateranges_yes() {
7077        let text = "#EXTM3U\n\
7078#EXT-X-VERSION:9\n\
7079#EXT-X-TARGETDURATION:4\n\
7080#EXT-X-PART-INF:PART-TARGET=0.5\n\
7081#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,CAN-SKIP-UNTIL=24.0,CAN-SKIP-DATERANGES=YES\n\
7082#EXTINF:4.000,\n\
7083seg0.m4s\n";
7084        let pl = MediaPlaylist::parse(text).expect("parse must succeed");
7085        let ll = pl.low_latency.as_ref().expect("must have low_latency");
7086        assert!(
7087            ll.can_skip_dateranges,
7088            "CAN-SKIP-DATERANGES=YES must parse to true"
7089        );
7090        let round = pl.to_m3u8().unwrap();
7091        assert!(
7092            round.contains("CAN-SKIP-DATERANGES=YES"),
7093            "render must emit CAN-SKIP-DATERANGES=YES:\n{round}"
7094        );
7095        let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
7096        let re_ll = reparse
7097            .low_latency
7098            .as_ref()
7099            .expect("must have low_latency on reparse");
7100        assert!(
7101            re_ll.can_skip_dateranges,
7102            "CAN-SKIP-DATERANGES=YES must survive round trip"
7103        );
7104    }
7105
7106    /// RFC 8216bis §4.4.3.8: `CAN-SKIP-DATERANGES` suppressed when
7107    /// `CAN-SKIP-UNTIL` is absent (REQUIRES relationship).
7108    #[test]
7109    fn can_skip_dateranges_not_rendered_without_can_skip_until() {
7110        let pl = MediaPlaylist {
7111            version: 9,
7112            target_duration: 4,
7113            media_sequence: 0,
7114            segments: vec![],
7115            low_latency: Some(LowLatencyConfig {
7116                part_target: Some(DecimalSeconds::new(0.5).unwrap()),
7117                part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
7118                can_skip_until: None,
7119                can_skip_dateranges: true,
7120                can_block_reload: true,
7121                ..Default::default()
7122            }),
7123            ..Default::default()
7124        };
7125        let out = pl.to_m3u8().unwrap();
7126        assert!(
7127            !out.contains("CAN-SKIP-DATERANGES"),
7128            "CAN-SKIP-DATERANGES must NOT render without CAN-SKIP-UNTIL:\n{out}"
7129        );
7130    }
7131
7132    /// RFC 8216bis §4.4.3.8: `HOLD-BACK` attribute survives parse ->
7133    /// serialize -> re-parse losslessly.
7134    #[test]
7135    fn parse_and_round_trip_hold_back() {
7136        let text = "#EXTM3U\n\
7137#EXT-X-VERSION:9\n\
7138#EXT-X-TARGETDURATION:4\n\
7139#EXT-X-PART-INF:PART-TARGET=0.5\n\
7140#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,HOLD-BACK=12.0\n\
7141#EXTINF:4.000,\n\
7142seg0.m4s\n";
7143        let pl = MediaPlaylist::parse(text).expect("parse must succeed");
7144        let ll = pl.low_latency.as_ref().expect("must have low_latency");
7145        assert_eq!(
7146            ll.hold_back,
7147            Some(DecimalSeconds::new(12.0).unwrap()),
7148            "HOLD-BACK=12.0 must parse"
7149        );
7150        let round = pl.to_m3u8().unwrap();
7151        // format_secs renders 12.0 as "12" (no trailing zero).
7152        assert!(
7153            round.contains("HOLD-BACK=12"),
7154            "render must emit HOLD-BACK=12:\n{round}"
7155        );
7156        let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
7157        let re_ll = reparse
7158            .low_latency
7159            .as_ref()
7160            .expect("must have low_latency on reparse");
7161        assert_eq!(
7162            re_ll.hold_back,
7163            Some(DecimalSeconds::new(12.0).unwrap()),
7164            "HOLD-BACK must survive round trip"
7165        );
7166    }
7167
7168    /// `HOLD-BACK` omitted when `None` (spec default: 3× Target Duration).
7169    #[test]
7170    fn hold_back_omitted_when_none() {
7171        let pl = MediaPlaylist {
7172            version: 9,
7173            target_duration: 4,
7174            media_sequence: 0,
7175            segments: vec![MediaSegment {
7176                uri: "seg0.m4s".into(),
7177                duration: DecimalSeconds::new(4.0).unwrap(),
7178                ..Default::default()
7179            }],
7180            low_latency: Some(LowLatencyConfig {
7181                part_target: Some(DecimalSeconds::new(0.5).unwrap()),
7182                part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
7183                hold_back: None,
7184                can_block_reload: true,
7185                ..Default::default()
7186            }),
7187            ..Default::default()
7188        };
7189        let out = pl.to_m3u8().unwrap();
7190        assert!(
7191            !out.contains(",HOLD-BACK="),
7192            "HOLD-BACK must be absent when None:\n{out}"
7193        );
7194    }
7195
7196    // --- F3: EXT-X-SESSION-KEY METHOD=NONE rejection ---
7197
7198    /// RFC 8216bis §4.4.6.5: `EXT-X-SESSION-KEY` MUST NOT have a METHOD of
7199    /// NONE. Parsing a playlist with `METHOD=NONE` must error.
7200    #[test]
7201    fn session_key_method_none_is_rejected() {
7202        let text = "#EXTM3U\n\
7203#EXT-X-VERSION:6\n\
7204#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
7205v300/index.m3u8\n\
7206#EXT-X-SESSION-KEY:METHOD=NONE,URI=\"https://k.example/key\"\n";
7207        let err = MasterPlaylist::parse(text)
7208            .expect_err("EXT-X-SESSION-KEY with METHOD=NONE must be rejected");
7209        let Error::HlsParse { reason, .. } = err else {
7210            panic!("expected Error::HlsParse, got {err:?}")
7211        };
7212        assert!(reason.contains("NONE"), "error must mention NONE: {reason}");
7213    }
7214
7215    /// `EXT-X-SESSION-KEY` with a real method (AES-128) is accepted and
7216    /// round-trips.
7217    #[test]
7218    fn session_key_real_method_is_accepted_and_round_trips() {
7219        let text = "#EXTM3U\n\
7220#EXT-X-VERSION:6\n\
7221#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
7222v300/index.m3u8\n\
7223#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"https://k.example/key\"\n";
7224        let pl = MasterPlaylist::parse(text).expect("legitimate session key must parse");
7225        assert_eq!(pl.session_keys.len(), 1);
7226        assert_eq!(pl.session_keys[0].method, EncryptionMethod::Aes128);
7227        let round = pl.to_m3u8().unwrap();
7228        let reparse = MasterPlaylist::parse(&round).expect("reparse must succeed");
7229        assert_eq!(reparse.session_keys.len(), 1);
7230        assert_eq!(reparse.session_keys[0].method, EncryptionMethod::Aes128);
7231    }
7232
7233    /// Interaction test (rebase #893 + #894): EXT-X-SERVER-CONTROL with
7234    /// typed attributes (HOLD-BACK, CAN-SKIP-DATERANGES) plus a REQ-
7235    /// attribute must round-trip all three and compute version 12
7236    /// (RFC 8216bis §8 row 12 + §4.4.3.8). The REQ- attribute must
7237    /// survive in `sc_extra_attrs`/`extra_attrs` and not be swallowed
7238    /// by the typed field parser.
7239    #[test]
7240    fn server_control_typed_and_req_attrs_round_trip_and_version_12() {
7241        let text = "#EXTM3U\n\
7242#EXT-X-VERSION:12\n\
7243#EXT-X-TARGETDURATION:4\n\
7244#EXT-X-PART-INF:PART-TARGET=0.5,REQ-VIDEO=720p\n\
7245#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\
7246#EXTINF:4,\n\
7247seg0.m4s\n";
7248        let pl = MediaPlaylist::parse(text).expect("parse must succeed");
7249        let ll = pl.low_latency.as_ref().expect("must have low_latency");
7250
7251        // Typed attributes survive.
7252        assert_eq!(ll.hold_back, Some(DecimalSeconds::new(12.0).unwrap()));
7253        assert!(ll.can_skip_dateranges);
7254
7255        // REQ-VIDEO from PART-INF → pi_extra_attrs.
7256        assert!(
7257            ll.pi_extra_attrs
7258                .iter()
7259                .any(|(k, v)| k == "REQ-VIDEO" && v.as_str() == "720p"),
7260            "REQ-VIDEO must survive in pi_extra_attrs"
7261        );
7262
7263        // REQ-LATENCY from SERVER-CONTROL → sc_extra_attrs.
7264        assert!(
7265            ll.sc_extra_attrs
7266                .iter()
7267                .any(|(k, v)| k == "REQ-LATENCY" && v.as_str() == "ultra-low"),
7268            "REQ-LATENCY must survive in sc_extra_attrs"
7269        );
7270
7271        // Each tag's own list keeps its own unmodeled attributes.
7272        assert!(ll.pi_extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
7273        assert!(ll.sc_extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
7274
7275        // Version must be 12.
7276        let round = pl.to_m3u8().unwrap();
7277        assert!(
7278            round.contains("#EXT-X-VERSION:12"),
7279            "must emit version 12:\n{round}"
7280        );
7281
7282        // Round-trip lossless.
7283        let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
7284        let re_ll = reparse
7285            .low_latency
7286            .as_ref()
7287            .expect("must have low_latency on reparse");
7288        assert_eq!(re_ll.hold_back, Some(DecimalSeconds::new(12.0).unwrap()));
7289        assert!(re_ll.can_skip_dateranges);
7290        assert!(re_ll.sc_extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
7291        assert!(re_ll.pi_extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
7292    }
7293
7294    // --- #958 overflow-rejection tests ---
7295
7296    #[test]
7297    fn byterange_offset_plus_length_overflow_rejected() {
7298        let input = "#EXTM3U\n\
7299                      #EXT-X-TARGETDURATION:10\n\
7300                      #EXTINF:10,\n\
7301                      #EXT-X-BYTERANGE:1@18446744073709551615\n\
7302                      seg0.ts\n";
7303        let err = MediaPlaylist::parse(input).unwrap_err();
7304        let msg = err.to_string();
7305        assert!(msg.contains("overflows"), "expected overflow error: {msg}");
7306    }
7307
7308    #[test]
7309    fn byterange_exact_max_accepted() {
7310        let input = "#EXTM3U\n\
7311                      #EXT-X-TARGETDURATION:10\n\
7312                      #EXTINF:10,\n\
7313                      #EXT-X-BYTERANGE:1@18446744073709551614\n\
7314                      seg0.ts\n";
7315        let pl = MediaPlaylist::parse(input).expect("u64::MAX exactly must parse");
7316        assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, 1);
7317        assert_eq!(
7318            pl.segments[0].byte_range.as_ref().unwrap().offset,
7319            Some(u64::MAX - 1)
7320        );
7321    }
7322
7323    #[test]
7324    fn byterange_no_offset_max_length_accepted() {
7325        let input = "#EXTM3U\n\
7326                      #EXT-X-TARGETDURATION:10\n\
7327                      #EXTINF:10,\n\
7328                      #EXT-X-BYTERANGE:18446744073709551615\n\
7329                      seg0.ts\n";
7330        let pl = MediaPlaylist::parse(input).expect("no offset means no overflow check");
7331        assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, u64::MAX);
7332        assert!(pl.segments[0].byte_range.as_ref().unwrap().offset.is_none());
7333    }
7334
7335    #[test]
7336    fn map_byterange_overflow_rejected() {
7337        let input = "#EXTM3U\n\
7338                      #EXT-X-TARGETDURATION:10\n\
7339                      #EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"1@18446744073709551615\"\n\
7340                      #EXTINF:10,\n\
7341                      seg0.ts\n";
7342        let err = MediaPlaylist::parse(input).unwrap_err();
7343        assert!(
7344            err.to_string().contains("overflows"),
7345            "MAP BYTERANGE overflow: {err}"
7346        );
7347    }
7348
7349    #[test]
7350    fn part_byterange_overflow_rejected() {
7351        let input = "#EXTM3U\n\
7352                      #EXT-X-TARGETDURATION:10\n\
7353                      #EXT-X-PART-INF:PART-TARGET=1.0\n\
7354                      #EXT-X-PART:URI=\"p0.m4s\",DURATION=1.0,BYTERANGE=\"1@18446744073709551615\"\n\
7355                      #EXTINF:10,\n\
7356                      seg0.ts\n";
7357        let err = MediaPlaylist::parse(input).unwrap_err();
7358        assert!(
7359            err.to_string().contains("overflows"),
7360            "PART BYTERANGE overflow: {err}"
7361        );
7362    }
7363
7364    #[test]
7365    fn preload_hint_byterange_overflow_rejected() {
7366        let input = "#EXTM3U\n\
7367                      #EXT-X-TARGETDURATION:10\n\
7368                      #EXT-X-PART-INF:PART-TARGET=1.0\n\
7369                      #EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"next.m4s\",\
7370                      BYTERANGE-START=18446744073709551615,BYTERANGE-LENGTH=1\n\
7371                      #EXTINF:10,\n\
7372                      seg0.ts\n";
7373        let err = MediaPlaylist::parse(input).unwrap_err();
7374        assert!(
7375            err.to_string().contains("overflows"),
7376            "PRELOAD-HINT overflow: {err}"
7377        );
7378    }
7379}