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