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