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