#![cfg_attr(not(feature = "std"), no_std)]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs)]
#![doc = "\n## Runnable examples\n"]
#![doc = "Run with `cargo run -p broadcast-hls --example <name>`.\n"]
#![doc = "\n### `build_playlist`\n\n```rust,ignore"]
#![doc = include_str!("../examples/build_playlist.rs")]
#![doc = "```\n\n### `parse_playlist`\n\n```rust,ignore"]
#![doc = include_str!("../examples/parse_playlist.rs")]
#![doc = "```"]
extern crate alloc;
mod error;
pub use error::{Error, Result};
use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use broadcast_common::hex::hex_encode;
pub use broadcast_common::CencScheme;
pub const CENC_KEYFORMAT: &str = "urn:mpeg:dash:mp4protection:2011";
pub const CENC_KEYFORMATVERSIONS: &str = "1";
pub fn cenc_ext_x_key(scheme: CencScheme, kid: &[u8; 16], key_uri: &str) -> Option<String> {
if scheme != CencScheme::Cbcs {
return None;
}
Some(format!(
"#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"{key_uri}\",KEYFORMAT=\"{CENC_KEYFORMAT}\",\
KEYFORMATVERSIONS=\"{CENC_KEYFORMATVERSIONS}\",KEYID=0x{}",
hex_encode(kid)
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ByteRange {
pub length: u64,
pub offset: Option<u64>,
}
impl ByteRange {
fn render(&self) -> String {
match self.offset {
Some(o) => format!("{}@{o}", self.length),
None => format!("{}", self.length),
}
}
fn parse(s: &str, line_no: usize, line: &str) -> Result<Self> {
let mut split = s.splitn(2, '@');
let n = split.next().unwrap_or("");
let length = parse_decimal::<u64>(n, line_no, line, "BYTERANGE length")?;
let offset = match split.next() {
Some(o) => Some(parse_decimal::<u64>(o, line_no, line, "BYTERANGE offset")?),
None => None,
};
Ok(ByteRange { length, offset })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MapTag {
pub uri: String,
pub byte_range: Option<ByteRange>,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum PreloadHintType {
#[default]
Part,
Map,
}
impl PreloadHintType {
pub fn name(&self) -> &'static str {
match self {
PreloadHintType::Part => "PART",
PreloadHintType::Map => "MAP",
}
}
}
broadcast_common::impl_spec_display!(PreloadHintType);
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RenditionReport {
pub uri: String,
pub last_msn: u64,
pub last_part: Option<u64>,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SkipInfo {
pub skipped_segments: u64,
pub recently_removed_daterange_ids: Vec<String>,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PartSpec {
pub uri: String,
pub duration: f64,
pub independent: bool,
pub byte_range: Option<ByteRange>,
pub gap: bool,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct OpenSegment {
pub parts: Vec<PartSpec>,
pub map: Option<MapTag>,
}
impl OpenSegment {
pub fn new(parts: Vec<PartSpec>) -> Self {
Self { parts, map: None }
}
pub fn with_map(mut self, map: MapTag) -> Self {
self.map = Some(map);
self
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct StartPoint {
pub time_offset: f64,
pub precise: bool,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Define {
Name {
name: String,
value: String,
extra_attrs: Vec<(String, String)>,
},
Import {
name: String,
extra_attrs: Vec<(String, String)>,
},
QueryParam {
name: String,
extra_attrs: Vec<(String, String)>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PlaylistType {
Event,
Vod,
}
impl PlaylistType {
pub fn name(&self) -> &'static str {
match self {
PlaylistType::Event => "EVENT",
PlaylistType::Vod => "VOD",
}
}
}
broadcast_common::impl_spec_display!(PlaylistType);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum SessionDataFormat {
#[default]
Json,
Raw,
}
impl SessionDataFormat {
pub fn name(&self) -> &'static str {
match self {
SessionDataFormat::Json => "JSON",
SessionDataFormat::Raw => "RAW",
}
}
}
broadcast_common::impl_spec_display!(SessionDataFormat);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SessionDataContent {
Value(String),
Uri {
uri: String,
format: SessionDataFormat,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SessionData {
pub data_id: String,
pub content: SessionDataContent,
pub language: Option<String>,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum EncryptionMethod {
None,
Aes128,
SampleAes,
SampleAesCtr,
Aes256Gcm,
}
impl EncryptionMethod {
pub fn name(&self) -> &'static str {
match self {
EncryptionMethod::None => "NONE",
EncryptionMethod::Aes128 => "AES-128",
EncryptionMethod::SampleAes => "SAMPLE-AES",
EncryptionMethod::SampleAesCtr => "SAMPLE-AES-CTR",
EncryptionMethod::Aes256Gcm => "AES-256-GCM",
}
}
}
broadcast_common::impl_spec_display!(EncryptionMethod);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SessionKey {
pub method: EncryptionMethod,
pub uri: Option<String>,
pub iv: Option<[u8; 16]>,
pub keyformat: Option<String>,
pub keyformatversions: Option<String>,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ContentSteering {
pub server_uri: String,
pub pathway_id: Option<String>,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MediaSegment {
pub uri: String,
pub duration: f64,
pub discontinuous: bool,
pub parts: Vec<PartSpec>,
pub byte_range: Option<ByteRange>,
pub map: Option<MapTag>,
pub gap: bool,
pub bitrate: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MediaPlaylist {
pub version: u8,
pub target_duration: u32,
pub media_sequence: u64,
pub discontinuity_sequence: u64,
pub segments: Vec<MediaSegment>,
pub open_segment: Option<OpenSegment>,
pub endlist: bool,
pub extra_tags: Vec<String>,
pub low_latency: Option<LowLatencyConfig>,
pub iframes_only: bool,
pub rendition_reports: Vec<RenditionReport>,
pub skip: Option<SkipInfo>,
pub independent_segments: bool,
pub start: Option<StartPoint>,
pub defines: Vec<Define>,
pub playlist_type: Option<PlaylistType>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LowLatencyConfig {
pub part_target: f64,
pub part_hold_back: f64,
pub preload_hint_part: Option<String>,
pub preload_hint_type: PreloadHintType,
pub preload_hint_byte_range_start: Option<u64>,
pub preload_hint_byte_range_length: Option<u64>,
pub can_skip_until: Option<f64>,
pub can_block_reload: bool,
pub extra_attrs: Vec<(String, String)>,
pub sc_extra_attrs: Vec<(String, String)>,
pub pi_extra_attrs: Vec<(String, String)>,
pub ph_extra_attrs: Vec<(String, String)>,
pub hold_back: Option<f64>,
pub can_skip_dateranges: bool,
}
impl LowLatencyConfig {
pub fn effective_part_hold_back(&self) -> f64 {
let floor = 3.0 * self.part_target;
if self.part_hold_back < floor {
floor
} else {
self.part_hold_back
}
}
}
impl Default for LowLatencyConfig {
fn default() -> Self {
Self {
part_target: 0.0,
part_hold_back: 0.0,
preload_hint_part: None,
preload_hint_type: PreloadHintType::default(),
preload_hint_byte_range_start: None,
preload_hint_byte_range_length: None,
can_skip_until: None,
can_block_reload: true,
extra_attrs: Vec::new(),
sc_extra_attrs: Vec::new(),
pi_extra_attrs: Vec::new(),
ph_extra_attrs: Vec::new(),
hold_back: None,
can_skip_dateranges: false,
}
}
}
const VERSION_KEY_IV: u8 = 2;
const VERSION_FLOAT_EXTINF: u8 = 3;
const VERSION_BYTERANGE_OR_IFRAMES_ONLY: u8 = 4;
const VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY: u8 = 5;
const VERSION_MAP_WITHOUT_IFRAMES_ONLY: u8 = 6;
const VERSION_MEDIA_SERVICE_INSTREAM_ID: u8 = 7;
const VERSION_VARIABLE_SUBSTITUTION: u8 = 8;
const VERSION_SKIP: u8 = 9;
const VERSION_SKIP_REPLACES_DATERANGE: u8 = 10;
const VERSION_DEFINE_QUERYPARAM: u8 = 11;
const VERSION_REQ_ATTRIBUTE: u8 = 12;
const VERSION_MEDIA_INSTREAM_ID_NON_CC: u8 = 13;
const VARIABLE_SUBSTITUTION_MARKER: &str = "{$";
fn bump_version(v: &mut Option<u8>, n: u8) {
*v = Some(match *v {
Some(m) => m.max(n),
None => n,
});
}
fn is_fractional_duration(duration: f64) -> bool {
format_extinf(duration).contains('.')
}
fn contains_variable_substitution(s: &str) -> bool {
s.contains(VARIABLE_SUBSTITUTION_MARKER)
}
fn scan_tag_lines_for_version(tags: &[String]) -> Option<u8> {
let mut v: Option<u8> = None;
for tag in tags {
if let Some(rest) = tag.strip_prefix("#EXT-X-KEY:") {
let attrs = parse_attr_list(rest);
if attrs.contains_key("IV") {
bump_version(&mut v, VERSION_KEY_IV);
}
if attrs.get("METHOD").map(String::as_str) == Some("SAMPLE-AES")
|| attrs.contains_key("KEYFORMAT")
|| attrs.contains_key("KEYFORMATVERSIONS")
{
bump_version(
&mut v,
VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY,
);
}
} else if let Some(rest) = tag.strip_prefix("#EXT-X-DEFINE:") {
if parse_attr_list(rest).contains_key("QUERYPARAM") {
bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
}
} else if let Some(rest) = tag.strip_prefix("#EXT-X-MEDIA:") {
let attrs = parse_attr_list(rest);
if let Some(instream_id) = attrs.get("INSTREAM-ID") {
if instream_id.starts_with("SERVICE") {
bump_version(&mut v, VERSION_MEDIA_SERVICE_INSTREAM_ID);
}
let is_closed_captions =
attrs.get("TYPE").map(String::as_str) == Some("CLOSED-CAPTIONS");
if !is_closed_captions {
bump_version(&mut v, VERSION_MEDIA_INSTREAM_ID_NON_CC);
}
}
}
if let Some(colon) = tag.find(':')
&& parse_attr_list(&tag[colon + 1..])
.keys()
.any(|k| k.starts_with("REQ-"))
{
bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
}
}
v
}
fn any_typed_req_attr(
start: Option<&StartPoint>,
defines: &[Define],
sd: &[SessionData],
sk: &[SessionKey],
cs: Option<&ContentSteering>,
variants: &[Variant],
iframes: &[IFrameVariant],
) -> bool {
let extra_attr_is_req =
|attrs: &[(String, String)]| -> bool { attrs.iter().any(|(k, _)| k.starts_with("REQ-")) };
if start.is_some_and(|s| extra_attr_is_req(&s.extra_attrs)) {
return true;
}
if defines.iter().any(|d| {
let attrs = match d {
Define::Name { extra_attrs, .. } => extra_attrs,
Define::Import { extra_attrs, .. } => extra_attrs,
Define::QueryParam { extra_attrs, .. } => extra_attrs,
};
extra_attr_is_req(attrs)
}) {
return true;
}
if sd.iter().any(|s| extra_attr_is_req(&s.extra_attrs)) {
return true;
}
if sk.iter().any(|s| extra_attr_is_req(&s.extra_attrs)) {
return true;
}
if cs.is_some_and(|c| extra_attr_is_req(&c.extra_attrs)) {
return true;
}
if variants.iter().any(|v| extra_attr_is_req(&v.extra_attrs)) {
return true;
}
if iframes.iter().any(|i| extra_attr_is_req(&i.extra_attrs)) {
return true;
}
false
}
fn any_media_typed_req_attr(
segments: &[MediaSegment],
open_segment: Option<&OpenSegment>,
rendition_reports: &[RenditionReport],
skip: Option<&SkipInfo>,
low_latency: Option<&LowLatencyConfig>,
) -> bool {
let extra_attr_is_req =
|attrs: &[(String, String)]| -> bool { attrs.iter().any(|(k, _)| k.starts_with("REQ-")) };
if segments.iter().any(|s| {
s.map
.as_ref()
.is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
|| s.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs))
}) {
return true;
}
if let Some(open) = open_segment
&& (open
.map
.as_ref()
.is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
|| open.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs)))
{
return true;
}
if rendition_reports
.iter()
.any(|r| extra_attr_is_req(&r.extra_attrs))
{
return true;
}
if skip.is_some_and(|s| extra_attr_is_req(&s.extra_attrs)) {
return true;
}
if low_latency.is_some_and(|ll| extra_attr_is_req(&ll.extra_attrs)) {
return true;
}
false
}
fn effective_version(explicit: u8, computed: Option<u8>) -> Option<u8> {
match (explicit, computed) {
(0, None) => None,
(0, Some(m)) => Some(m),
(e, None) => Some(e),
(e, Some(m)) => Some(e.max(m)),
}
}
impl MediaPlaylist {
pub fn computed_version(&self) -> Option<u8> {
let mut v: Option<u8> = None;
let has_map = self.segments.iter().any(|s| s.map.is_some())
|| self.open_segment.as_ref().is_some_and(|o| o.map.is_some())
|| self.extra_tags.iter().any(|t| t.starts_with("#EXT-X-MAP:"));
if has_map {
bump_version(
&mut v,
if self.iframes_only {
VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY
} else {
VERSION_MAP_WITHOUT_IFRAMES_ONLY
},
);
}
if self.iframes_only || self.segments.iter().any(|s| s.byte_range.is_some()) {
bump_version(&mut v, VERSION_BYTERANGE_OR_IFRAMES_ONLY);
}
if self
.segments
.iter()
.any(|s| is_fractional_duration(s.duration))
{
bump_version(&mut v, VERSION_FLOAT_EXTINF);
}
if let Some(skip) = &self.skip {
bump_version(&mut v, VERSION_SKIP);
if !skip.recently_removed_daterange_ids.is_empty() {
bump_version(&mut v, VERSION_SKIP_REPLACES_DATERANGE);
}
}
if self
.defines
.iter()
.any(|d| matches!(d, Define::QueryParam { .. }))
{
bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
}
if self
.extra_tags
.iter()
.any(|t| contains_variable_substitution(t))
|| self
.segments
.iter()
.any(|s| contains_variable_substitution(&s.uri))
|| self.media_playlist_typed_strings_use_substitution()
{
bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
}
if let Some(m) = scan_tag_lines_for_version(&self.extra_tags) {
bump_version(&mut v, m);
}
if any_media_typed_req_attr(
&self.segments,
self.open_segment.as_ref(),
&self.rendition_reports,
self.skip.as_ref(),
self.low_latency.as_ref(),
) || any_typed_req_attr(self.start.as_ref(), &self.defines, &[], &[], None, &[], &[])
{
bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
}
v
}
fn media_playlist_typed_strings_use_substitution(&self) -> bool {
let seg_strings = self.segments.iter().flat_map(|s| {
s.map
.iter()
.map(|m| &m.uri)
.chain(s.parts.iter().map(|p| &p.uri))
});
let open_strings = self.open_segment.iter().flat_map(|o| {
o.map
.iter()
.map(|m| &m.uri)
.chain(o.parts.iter().map(|p| &p.uri))
});
let define_values = self.defines.iter().filter_map(|d| match d {
Define::Name { value, .. } => Some(value),
_ => None,
});
let report_uris = self.rendition_reports.iter().map(|r| &r.uri);
let preload = self
.low_latency
.iter()
.filter_map(|ll| ll.preload_hint_part.as_ref());
seg_strings
.chain(open_strings)
.chain(define_values)
.chain(report_uris)
.chain(preload)
.any(|s| contains_variable_substitution(s))
}
fn effective_version(&self) -> Option<u8> {
effective_version(self.version, self.computed_version())
}
}
impl MediaPlaylist {
pub fn to_m3u8(&self) -> String {
let mut s = String::new();
s.push_str("#EXTM3U\n");
if let Some(version) = self.effective_version() {
s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
}
if self.iframes_only {
s.push_str("#EXT-X-I-FRAMES-ONLY\n");
}
if self.independent_segments {
s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
}
for def in &self.defines {
push_define_line(&mut s, def);
}
if let Some(start) = &self.start {
push_start_line(&mut s, start);
}
s.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", self.target_duration));
s.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", self.media_sequence));
if self.discontinuity_sequence > 0 {
s.push_str(&format!(
"#EXT-X-DISCONTINUITY-SEQUENCE:{}\n",
self.discontinuity_sequence
));
}
if let Some(pt) = self.playlist_type {
s.push_str(&format!("#EXT-X-PLAYLIST-TYPE:{}\n", pt.name()));
}
if let Some(ll) = &self.low_latency {
s.push_str(&format!(
"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD={},PART-HOLD-BACK={}",
if ll.can_block_reload { "YES" } else { "NO" },
format_secs(ll.effective_part_hold_back()),
));
if let Some(hb) = ll.hold_back {
s.push_str(&format!(",HOLD-BACK={}", format_secs(hb)));
}
if let Some(csu) = ll.can_skip_until {
s.push_str(&format!(",CAN-SKIP-UNTIL={}", format_secs(csu)));
if ll.can_skip_dateranges {
s.push_str(",CAN-SKIP-DATERANGES=YES");
}
}
push_extra_attrs(&mut s, &ll.sc_extra_attrs);
s.push('\n');
s.push_str(&format!(
"#EXT-X-PART-INF:PART-TARGET={}",
format_secs(ll.part_target),
));
push_extra_attrs(&mut s, &ll.pi_extra_attrs);
s.push('\n');
}
if let Some(skip) = &self.skip {
s.push_str(&format!(
"#EXT-X-SKIP:SKIPPED-SEGMENTS={}",
skip.skipped_segments
));
if !skip.recently_removed_daterange_ids.is_empty() {
s.push_str(&format!(
",RECENTLY-REMOVED-DATERANGES=\"{}\"",
skip.recently_removed_daterange_ids.join("\t")
));
}
push_extra_attrs(&mut s, &skip.extra_attrs);
s.push('\n');
}
for tag in &self.extra_tags {
s.push_str(tag);
s.push('\n');
}
for (i, seg) in self.segments.iter().enumerate() {
if seg.discontinuous {
s.push_str("#EXT-X-DISCONTINUITY\n");
}
let prev_map = if i == 0 {
None
} else {
self.segments[i - 1].map.as_ref()
};
if seg.map.as_ref() != prev_map
&& let Some(map) = &seg.map
{
push_map_line(&mut s, map);
}
let prev_bitrate = if i == 0 {
None
} else {
self.segments[i - 1].bitrate
};
if seg.bitrate != prev_bitrate
&& let Some(kbps) = seg.bitrate
{
s.push_str(&format!("#EXT-X-BITRATE:{kbps}\n"));
}
if self.low_latency.is_some() {
for part in &seg.parts {
push_part_line(&mut s, part);
}
}
if seg.gap {
s.push_str("#EXT-X-GAP\n");
}
s.push_str(&format!("#EXTINF:{},\n", format_extinf(seg.duration)));
if let Some(br) = &seg.byte_range {
s.push_str(&format!("#EXT-X-BYTERANGE:{}\n", br.render()));
}
s.push_str(&seg.uri);
s.push('\n');
}
if self.low_latency.is_some()
&& let Some(open) = &self.open_segment
{
let prev_map = self.segments.last().and_then(|s| s.map.as_ref());
if open.map.as_ref() != prev_map
&& let Some(map) = &open.map
{
push_map_line(&mut s, map);
}
for part in &open.parts {
push_part_line(&mut s, part);
}
}
if let Some(ll) = &self.low_latency
&& let Some(uri) = &ll.preload_hint_part
{
s.push_str(&format!(
"#EXT-X-PRELOAD-HINT:TYPE={},URI=\"{uri}\"",
ll.preload_hint_type.name(),
));
if let Some(start) = ll.preload_hint_byte_range_start {
s.push_str(&format!(",BYTERANGE-START={start}"));
}
if let Some(len) = ll.preload_hint_byte_range_length {
s.push_str(&format!(",BYTERANGE-LENGTH={len}"));
}
push_extra_attrs(&mut s, &ll.ph_extra_attrs);
s.push('\n');
}
for rr in &self.rendition_reports {
s.push_str(&format!(
"#EXT-X-RENDITION-REPORT:URI=\"{}\",LAST-MSN={}",
rr.uri, rr.last_msn
));
if let Some(lp) = rr.last_part {
s.push_str(&format!(",LAST-PART={lp}"));
}
push_extra_attrs(&mut s, &rr.extra_attrs);
s.push('\n');
}
if self.endlist {
s.push_str("#EXT-X-ENDLIST\n");
}
s
}
pub fn parse(input: &str) -> Result<Self> {
let mut version: u8 = 0;
let mut target_duration: Option<u32> = None;
let mut media_sequence: u64 = 0;
let mut discontinuity_sequence: u64 = 0;
let mut iframes_only = false;
let mut endlist = false;
let mut extra_tags: Vec<String> = Vec::new();
let mut segments: Vec<MediaSegment> = Vec::new();
let mut rendition_reports: Vec<RenditionReport> = Vec::new();
let mut skip: Option<SkipInfo> = None;
let mut saw_extm3u = false;
let mut independent_segments = false;
let mut start: Option<StartPoint> = None;
let mut defines: Vec<Define> = Vec::new();
let mut playlist_type: Option<PlaylistType> = None;
let mut part_target: Option<f64> = None;
let mut part_hold_back: Option<f64> = None;
let mut can_skip_until: Option<f64> = None;
let mut can_skip_dateranges = false;
let mut hold_back: Option<f64> = None;
let mut can_block_reload = false;
let mut preload_hint_part: Option<String> = None;
let mut preload_hint_type = PreloadHintType::Part;
let mut preload_hint_byte_range_start: Option<u64> = None;
let mut preload_hint_byte_range_length: Option<u64> = None;
let mut saw_ll_tag = false;
let mut sc_extra_attrs: Vec<(String, String)> = Vec::new();
let mut pi_extra_attrs: Vec<(String, String)> = Vec::new();
let mut ph_extra_attrs: Vec<(String, String)> = Vec::new();
let mut current_map: Option<MapTag> = None;
let mut pending_discontinuous = false;
let mut pending_byte_range: Option<ByteRange> = None;
let mut pending_parts: Vec<PartSpec> = Vec::new();
let mut pending_duration: Option<f64> = None;
let mut pending_gap = false;
let mut current_bitrate: Option<u64> = None;
for (idx, raw_line) in input.lines().enumerate() {
let line_no = idx + 1;
let mut line = raw_line.trim_end_matches('\r');
if line_no == 1 {
line = line.strip_prefix('\u{feff}').unwrap_or(line);
}
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "#EXTM3U" {
saw_extm3u = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
version = parse_decimal(rest, line_no, line, "EXT-X-VERSION")?;
} else if let Some(rest) = line.strip_prefix("#EXT-X-TARGETDURATION:") {
target_duration = Some(parse_decimal(rest, line_no, line, "EXT-X-TARGETDURATION")?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
media_sequence = parse_decimal(rest, line_no, line, "EXT-X-MEDIA-SEQUENCE")?;
} else if let Some(rest) = line.strip_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:") {
discontinuity_sequence =
parse_decimal(rest, line_no, line, "EXT-X-DISCONTINUITY-SEQUENCE")?;
} else if line == "#EXT-X-I-FRAMES-ONLY" {
iframes_only = true;
} else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
independent_segments = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
start = Some(parse_start(rest, line_no, line)?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
defines.push(parse_define(rest, line_no, line)?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-PLAYLIST-TYPE:") {
playlist_type = Some(match rest.trim() {
"EVENT" => PlaylistType::Event,
"VOD" => PlaylistType::Vod,
other => {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!(
"EXT-X-PLAYLIST-TYPE value {other:?} is neither EVENT nor VOD"
),
});
}
});
} else if line == "#EXT-X-ENDLIST" {
endlist = true;
} else if line == "#EXT-X-DISCONTINUITY" {
pending_discontinuous = true;
} else if line == "#EXT-X-GAP" {
pending_gap = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-BITRATE:") {
current_bitrate = Some(parse_decimal(rest, line_no, line, "EXT-X-BITRATE")?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-BYTERANGE:") {
pending_byte_range = Some(ByteRange::parse(rest, line_no, line)?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-MAP:") {
let attrs = parse_attr_list(rest);
let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-MAP")?;
let byte_range = match attrs.get("BYTERANGE") {
Some(v) => Some(ByteRange::parse(v, line_no, line)?),
None => None,
};
let extra_attrs = filter_extra_attrs(&attrs, &["URI", "BYTERANGE"]);
current_map = Some(MapTag {
uri,
byte_range,
extra_attrs,
});
} else if let Some(rest) = line.strip_prefix("#EXTINF:") {
let dur_str = rest.split(',').next().unwrap_or(rest);
pending_duration = Some(parse_decimal(dur_str, line_no, line, "EXTINF duration")?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-PART-INF:") {
let attrs = parse_attr_list(rest);
if let Some(v) = attrs.get("PART-TARGET") {
part_target = Some(parse_decimal(v, line_no, line, "PART-TARGET")?);
}
pi_extra_attrs.extend(filter_extra_attrs(&attrs, &["PART-TARGET"]));
saw_ll_tag = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-SERVER-CONTROL:") {
let attrs = parse_attr_list(rest);
if let Some(v) = attrs.get("PART-HOLD-BACK") {
part_hold_back = Some(parse_decimal(v, line_no, line, "PART-HOLD-BACK")?);
}
if let Some(v) = attrs.get("CAN-SKIP-UNTIL") {
can_skip_until = Some(parse_decimal(v, line_no, line, "CAN-SKIP-UNTIL")?);
}
can_skip_dateranges =
attrs.get("CAN-SKIP-DATERANGES").map(String::as_str) == Some("YES");
can_block_reload = attrs.get("CAN-BLOCK-RELOAD").map(String::as_str) == Some("YES");
if let Some(v) = attrs.get("HOLD-BACK") {
hold_back = Some(parse_decimal(v, line_no, line, "HOLD-BACK")?);
}
sc_extra_attrs.extend(filter_extra_attrs(
&attrs,
&[
"PART-HOLD-BACK",
"CAN-SKIP-UNTIL",
"CAN-BLOCK-RELOAD",
"HOLD-BACK",
"CAN-SKIP-DATERANGES",
],
));
saw_ll_tag = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-PART:") {
let attrs = parse_attr_list(rest);
let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-PART")?;
let duration_str = attrs.get("DURATION").ok_or_else(|| Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-PART missing required DURATION attribute".to_string(),
})?;
let duration = parse_decimal(duration_str, line_no, line, "EXT-X-PART DURATION")?;
let independent = attrs.get("INDEPENDENT").map(String::as_str) == Some("YES");
let gap = attrs.get("GAP").map(String::as_str) == Some("YES");
let byte_range = match attrs.get("BYTERANGE") {
Some(v) => Some(ByteRange::parse(v, line_no, line)?),
None => None,
};
let extra_attrs = filter_extra_attrs(
&attrs,
&["URI", "DURATION", "INDEPENDENT", "GAP", "BYTERANGE"],
);
pending_parts.push(PartSpec {
uri,
duration,
independent,
byte_range,
gap,
extra_attrs,
});
saw_ll_tag = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-PRELOAD-HINT:") {
let attrs = parse_attr_list(rest);
preload_hint_type = match attrs.get("TYPE").map(String::as_str) {
Some("MAP") => PreloadHintType::Map,
_ => PreloadHintType::Part,
};
preload_hint_part = Some(require_attr(
&attrs,
"URI",
line_no,
line,
"EXT-X-PRELOAD-HINT",
)?);
if let Some(v) = attrs.get("BYTERANGE-START") {
preload_hint_byte_range_start =
Some(parse_decimal(v, line_no, line, "BYTERANGE-START")?);
}
if let Some(v) = attrs.get("BYTERANGE-LENGTH") {
preload_hint_byte_range_length =
Some(parse_decimal(v, line_no, line, "BYTERANGE-LENGTH")?);
}
ph_extra_attrs.extend(filter_extra_attrs(
&attrs,
&["TYPE", "URI", "BYTERANGE-START", "BYTERANGE-LENGTH"],
));
saw_ll_tag = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-RENDITION-REPORT:") {
let attrs = parse_attr_list(rest);
let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-RENDITION-REPORT")?;
let last_msn = match attrs.get("LAST-MSN") {
Some(v) => parse_decimal(v, line_no, line, "LAST-MSN")?,
None => 0,
};
let last_part = match attrs.get("LAST-PART") {
Some(v) => Some(parse_decimal(v, line_no, line, "LAST-PART")?),
None => None,
};
let extra_attrs = filter_extra_attrs(&attrs, &["URI", "LAST-MSN", "LAST-PART"]);
rendition_reports.push(RenditionReport {
uri,
last_msn,
last_part,
extra_attrs,
});
} else if let Some(rest) = line.strip_prefix("#EXT-X-SKIP:") {
let attrs = parse_attr_list(rest);
let skipped_segments_str =
require_attr(&attrs, "SKIPPED-SEGMENTS", line_no, line, "EXT-X-SKIP")?;
let skipped_segments =
parse_decimal(&skipped_segments_str, line_no, line, "SKIPPED-SEGMENTS")?;
let recently_removed_daterange_ids = attrs
.get("RECENTLY-REMOVED-DATERANGES")
.map(|v| {
v.split('\t')
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.collect()
})
.unwrap_or_default();
let extra_attrs = filter_extra_attrs(
&attrs,
&["SKIPPED-SEGMENTS", "RECENTLY-REMOVED-DATERANGES"],
);
skip = Some(SkipInfo {
skipped_segments,
recently_removed_daterange_ids,
extra_attrs,
});
} else if let Some(rest) = line.strip_prefix("#EXT") {
let _ = rest;
extra_tags.push(line.to_string());
} else if line.starts_with('#') {
} else {
let duration = pending_duration.take().ok_or_else(|| Error::HlsParse {
line_no,
line: line.to_string(),
reason: "media segment URI with no preceding #EXTINF".to_string(),
})?;
segments.push(MediaSegment {
uri: line.to_string(),
duration,
discontinuous: core::mem::take(&mut pending_discontinuous),
parts: core::mem::take(&mut pending_parts),
byte_range: pending_byte_range.take(),
map: current_map.clone(),
gap: core::mem::take(&mut pending_gap),
bitrate: current_bitrate,
});
}
}
if !saw_extm3u {
return Err(Error::HlsParse {
line_no: 1,
line: String::new(),
reason: "missing #EXTM3U header".to_string(),
});
}
let target_duration = target_duration.ok_or_else(|| Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "missing required #EXT-X-TARGETDURATION".to_string(),
})?;
let open_segment = if pending_parts.is_empty() {
None
} else {
let open = OpenSegment::new(pending_parts);
Some(match ¤t_map {
Some(map) => open.with_map(map.clone()),
None => open,
})
};
let low_latency = if saw_ll_tag {
let mut all_extra: Vec<(String, String)> = Vec::new();
all_extra.extend(sc_extra_attrs.iter().cloned());
all_extra.extend(pi_extra_attrs.iter().cloned());
all_extra.extend(ph_extra_attrs.iter().cloned());
Some(LowLatencyConfig {
part_target: part_target.unwrap_or(0.0),
part_hold_back: part_hold_back.unwrap_or(0.0),
preload_hint_part,
preload_hint_type,
preload_hint_byte_range_start,
preload_hint_byte_range_length,
can_skip_until,
can_block_reload,
extra_attrs: all_extra,
sc_extra_attrs,
pi_extra_attrs,
ph_extra_attrs,
hold_back,
can_skip_dateranges,
})
} else {
None
};
Ok(MediaPlaylist {
version,
target_duration,
media_sequence,
discontinuity_sequence,
segments,
open_segment,
endlist,
extra_tags,
low_latency,
iframes_only,
rendition_reports,
skip,
independent_segments,
start,
defines,
playlist_type,
})
}
}
fn push_part_line(s: &mut String, part: &PartSpec) {
s.push_str(&format!(
"#EXT-X-PART:DURATION={},URI=\"{}\"",
format_secs(part.duration),
part.uri,
));
if let Some(br) = &part.byte_range {
s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
}
if part.independent {
s.push_str(",INDEPENDENT=YES");
}
if part.gap {
s.push_str(",GAP=YES");
}
push_extra_attrs(s, &part.extra_attrs);
s.push('\n');
}
fn push_map_line(s: &mut String, map: &MapTag) {
s.push_str(&format!("#EXT-X-MAP:URI=\"{}\"", map.uri));
if let Some(br) = &map.byte_range {
s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
}
push_extra_attrs(s, &map.extra_attrs);
s.push('\n');
}
fn push_define_line(s: &mut String, def: &Define) {
match def {
Define::Name {
name,
value,
extra_attrs,
} => {
s.push_str(&format!("#EXT-X-DEFINE:NAME=\"{name}\",VALUE=\"{value}\""));
push_extra_attrs(s, extra_attrs);
s.push('\n');
}
Define::Import { name, extra_attrs } => {
s.push_str(&format!("#EXT-X-DEFINE:IMPORT=\"{name}\""));
push_extra_attrs(s, extra_attrs);
s.push('\n');
}
Define::QueryParam { name, extra_attrs } => {
s.push_str(&format!("#EXT-X-DEFINE:QUERYPARAM=\"{name}\""));
push_extra_attrs(s, extra_attrs);
s.push('\n');
}
}
}
fn parse_define(rest: &str, line_no: usize, line: &str) -> Result<Define> {
let attrs = parse_attr_list(rest);
let present = [
attrs.contains_key("NAME"),
attrs.contains_key("IMPORT"),
attrs.contains_key("QUERYPARAM"),
]
.iter()
.filter(|&&b| b)
.count();
if present != 1 {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-DEFINE must contain exactly one of NAME, IMPORT, QUERYPARAM".to_string(),
});
}
if let Some(name) = attrs.get("NAME") {
let value = require_attr(&attrs, "VALUE", line_no, line, "EXT-X-DEFINE")?;
let extra_attrs = filter_extra_attrs(&attrs, &["NAME", "VALUE"]);
Ok(Define::Name {
name: name.clone(),
value,
extra_attrs,
})
} else if let Some(name) = attrs.get("IMPORT") {
let extra_attrs = filter_extra_attrs(&attrs, &["IMPORT"]);
Ok(Define::Import {
name: name.clone(),
extra_attrs,
})
} else {
let name = attrs
.get("QUERYPARAM")
.expect("exactly one of the three checked above")
.clone();
let extra_attrs = filter_extra_attrs(&attrs, &["QUERYPARAM"]);
Ok(Define::QueryParam { name, extra_attrs })
}
}
fn push_extra_attrs(s: &mut String, attrs: &[(String, String)]) {
for (name, value) in attrs {
s.push(',');
s.push_str(name);
s.push('=');
if value.contains(',') || value.contains('"') || value.contains(char::is_whitespace) {
s.push('"');
s.push_str(value);
s.push('"');
} else {
s.push_str(value);
}
}
}
fn push_start_line(s: &mut String, start: &StartPoint) {
s.push_str(&format!(
"#EXT-X-START:TIME-OFFSET={}",
format_signed_secs(start.time_offset)
));
if start.precise {
s.push_str(",PRECISE=YES");
}
push_extra_attrs(s, &start.extra_attrs);
s.push('\n');
}
fn parse_start(rest: &str, line_no: usize, line: &str) -> Result<StartPoint> {
let attrs = parse_attr_list(rest);
let time_offset_str = require_attr(&attrs, "TIME-OFFSET", line_no, line, "EXT-X-START")?;
let time_offset = parse_decimal(&time_offset_str, line_no, line, "TIME-OFFSET")?;
let precise = attrs.get("PRECISE").map(String::as_str) == Some("YES");
let extra_attrs = filter_extra_attrs(&attrs, &["TIME-OFFSET", "PRECISE"]);
Ok(StartPoint {
time_offset,
precise,
extra_attrs,
})
}
fn push_session_data_line(s: &mut String, sd: &SessionData) {
s.push_str(&format!("#EXT-X-SESSION-DATA:DATA-ID=\"{}\"", sd.data_id));
match &sd.content {
SessionDataContent::Value(v) => {
s.push_str(&format!(",VALUE=\"{v}\""));
}
SessionDataContent::Uri { uri, format } => {
s.push_str(&format!(",URI=\"{uri}\""));
if *format == SessionDataFormat::Raw {
s.push_str(",FORMAT=RAW");
}
}
}
if let Some(lang) = &sd.language {
s.push_str(&format!(",LANGUAGE=\"{lang}\""));
}
push_extra_attrs(s, &sd.extra_attrs);
s.push('\n');
}
fn parse_session_data(rest: &str, line_no: usize, line: &str) -> Result<SessionData> {
let attrs = parse_attr_list(rest);
let data_id = require_attr(&attrs, "DATA-ID", line_no, line, "EXT-X-SESSION-DATA")?;
let value = attrs.get("VALUE");
let uri = attrs.get("URI");
let content = match (value, uri) {
(Some(v), None) => SessionDataContent::Value(v.clone()),
(None, Some(u)) => {
let format = match attrs.get("FORMAT").map(String::as_str) {
Some("RAW") => SessionDataFormat::Raw,
_ => SessionDataFormat::Json,
};
SessionDataContent::Uri {
uri: u.clone(),
format,
}
}
(Some(_), Some(_)) => {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-SESSION-DATA must not contain both VALUE and URI".to_string(),
});
}
(None, None) => {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-SESSION-DATA must contain either VALUE or URI".to_string(),
});
}
};
let language = attrs.get("LANGUAGE").cloned();
let known: &[&str] = match &content {
SessionDataContent::Value(_) => &["DATA-ID", "VALUE", "LANGUAGE"],
SessionDataContent::Uri { .. } => &["DATA-ID", "URI", "FORMAT", "LANGUAGE"],
};
let extra_attrs = filter_extra_attrs(&attrs, known);
Ok(SessionData {
data_id,
content,
language,
extra_attrs,
})
}
fn push_session_key_line(s: &mut String, sk: &SessionKey) {
s.push_str(&format!("#EXT-X-SESSION-KEY:METHOD={}", sk.method.name()));
if let Some(uri) = &sk.uri {
s.push_str(&format!(",URI=\"{uri}\""));
}
if let Some(iv) = &sk.iv {
s.push_str(&format!(",IV=0x{}", hex_encode(iv)));
}
if let Some(kf) = &sk.keyformat {
s.push_str(&format!(",KEYFORMAT=\"{kf}\""));
}
if let Some(kfv) = &sk.keyformatversions {
s.push_str(&format!(",KEYFORMATVERSIONS=\"{kfv}\""));
}
push_extra_attrs(s, &sk.extra_attrs);
s.push('\n');
}
fn parse_session_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
let attrs = parse_attr_list(rest);
let method_str = require_attr(&attrs, "METHOD", line_no, line, "EXT-X-SESSION-KEY")?;
let method = match method_str.as_str() {
"NONE" => {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-SESSION-KEY METHOD MUST NOT be NONE (RFC 8216bis §4.4.6.5)"
.to_string(),
});
}
"AES-128" => EncryptionMethod::Aes128,
"SAMPLE-AES" => EncryptionMethod::SampleAes,
"SAMPLE-AES-CTR" => EncryptionMethod::SampleAesCtr,
"AES-256-GCM" => EncryptionMethod::Aes256Gcm,
other => {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("EXT-X-SESSION-KEY METHOD value {other:?} is not recognized"),
});
}
};
let uri = attrs.get("URI").cloned();
let iv = match attrs.get("IV") {
Some(v) => Some(parse_iv(v, line_no, line)?),
None => None,
};
let keyformat = attrs.get("KEYFORMAT").cloned();
let keyformatversions = attrs.get("KEYFORMATVERSIONS").cloned();
let extra_attrs = filter_extra_attrs(
&attrs,
&["METHOD", "URI", "IV", "KEYFORMAT", "KEYFORMATVERSIONS"],
);
Ok(SessionKey {
method,
uri,
iv,
keyformat,
keyformatversions,
extra_attrs,
})
}
fn parse_iv(s: &str, line_no: usize, line: &str) -> Result<[u8; 16]> {
let hex = s
.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.unwrap_or(s);
if hex.len() != 32 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("IV value {s:?} is not a 128-bit (32 hex digit) sequence"),
});
}
let mut out = [0u8; 16];
for i in 0..16 {
out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex digits");
}
Ok(out)
}
fn format_signed_secs(v: f64) -> String {
if v < 0.0 {
format!("-{}", format_secs(-v))
} else {
format_secs(v)
}
}
fn format_secs(v: f64) -> String {
let millis = (v * 1000.0 + 0.5) as u64;
let whole = millis / 1000;
let frac = millis % 1000;
let compact = if frac == 0 {
format!("{whole}")
} else {
let mut f = format!("{frac:03}");
while f.ends_with('0') {
f.pop();
}
format!("{whole}.{f}")
};
if compact.parse::<f64>() == Ok(v) {
return compact;
}
format!("{v}")
}
fn format_extinf(v: f64) -> String {
if let Some(whole) = whole_seconds(v) {
return format!("{whole}");
}
let three = format!("{v:.3}");
if three.parse::<f64>() == Ok(v) {
return three;
}
format!("{v}")
}
fn whole_seconds(v: f64) -> Option<u64> {
if !v.is_finite() || !(0.0..WHOLE_SECONDS_CAST_LIMIT).contains(&v) {
return None;
}
let whole = v as u64;
(whole as f64 == v).then_some(whole)
}
const WHOLE_SECONDS_CAST_LIMIT: f64 = 9_007_199_254_740_992.0;
fn parse_decimal<T: core::str::FromStr>(
s: &str,
line_no: usize,
line: &str,
what: &str,
) -> Result<T> {
s.trim().parse::<T>().map_err(|_| Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("{what} value {s:?} is not a valid number"),
})
}
fn parse_attr_list(s: &str) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
let bytes = s.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
while i < len && (bytes[i] == b',' || bytes[i].is_ascii_whitespace()) {
i += 1;
}
if i >= len {
break;
}
let key_start = i;
while i < len && bytes[i] != b'=' {
i += 1;
}
if i >= len {
break;
}
let key = &s[key_start..i];
i += 1; if i < len && bytes[i] == b'"' {
i += 1;
let value_start = i;
while i < len && bytes[i] != b'"' {
i += 1;
}
let value = &s[value_start..i];
if i < len {
i += 1; }
map.insert(key.to_string(), value.to_string());
} else {
let value_start = i;
while i < len && bytes[i] != b',' {
i += 1;
}
map.insert(key.to_string(), s[value_start..i].to_string());
}
}
map
}
fn filter_extra_attrs(attrs: &BTreeMap<String, String>, known: &[&str]) -> Vec<(String, String)> {
attrs
.iter()
.filter(|(k, _)| !known.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
fn require_attr(
attrs: &BTreeMap<String, String>,
key: &str,
line_no: usize,
line: &str,
tag: &str,
) -> Result<String> {
attrs.get(key).cloned().ok_or_else(|| Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("{tag} missing required {key} attribute"),
})
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Variant {
pub bandwidth: u32,
pub codecs: String,
pub resolution: Option<(u32, u32)>,
pub uri: String,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct IFrameVariant {
pub bandwidth: u32,
pub codecs: Option<String>,
pub resolution: Option<(u32, u32)>,
pub uri: String,
pub extra_attrs: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MasterPlaylist {
pub version: u8,
pub variants: Vec<Variant>,
pub iframe_variants: Vec<IFrameVariant>,
pub extra_tags: Vec<String>,
pub independent_segments: bool,
pub start: Option<StartPoint>,
pub defines: Vec<Define>,
pub session_data: Vec<SessionData>,
pub session_keys: Vec<SessionKey>,
pub content_steering: Option<ContentSteering>,
}
type PendingStreamInf = (u32, String, Option<(u32, u32)>, Vec<(String, String)>);
impl MasterPlaylist {
pub fn to_m3u8(&self) -> String {
let mut s = String::new();
s.push_str("#EXTM3U\n");
if let Some(version) = self.effective_version() {
s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
}
for tag in &self.extra_tags {
s.push_str(tag);
s.push('\n');
}
if self.independent_segments {
s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
}
for def in &self.defines {
push_define_line(&mut s, def);
}
if let Some(start) = &self.start {
push_start_line(&mut s, start);
}
for sk in &self.session_keys {
push_session_key_line(&mut s, sk);
}
for sd in &self.session_data {
push_session_data_line(&mut s, sd);
}
if let Some(cs) = &self.content_steering {
s.push_str(&format!(
"#EXT-X-CONTENT-STEERING:SERVER-URI=\"{}\"",
cs.server_uri
));
if let Some(pid) = &cs.pathway_id {
s.push_str(&format!(",PATHWAY-ID=\"{pid}\""));
}
push_extra_attrs(&mut s, &cs.extra_attrs);
s.push('\n');
}
for var in &self.variants {
s.push_str(&format!(
"#EXT-X-STREAM-INF:BANDWIDTH={},CODECS=\"{}\"",
var.bandwidth, var.codecs,
));
if let Some((w, h)) = var.resolution {
s.push_str(&format!(",RESOLUTION={w}x{h}"));
}
push_extra_attrs(&mut s, &var.extra_attrs);
s.push('\n');
s.push_str(&var.uri);
s.push('\n');
}
for iv in &self.iframe_variants {
s.push_str(&format!(
"#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH={}",
iv.bandwidth
));
if let Some(c) = &iv.codecs {
s.push_str(&format!(",CODECS=\"{c}\""));
}
if let Some((w, h)) = iv.resolution {
s.push_str(&format!(",RESOLUTION={w}x{h}"));
}
push_extra_attrs(&mut s, &iv.extra_attrs);
s.push_str(&format!(",URI=\"{}\"\n", iv.uri));
}
s
}
pub fn parse(input: &str) -> Result<Self> {
let mut version: u8 = 0;
let mut variants: Vec<Variant> = Vec::new();
let mut iframe_variants: Vec<IFrameVariant> = Vec::new();
let mut extra_tags: Vec<String> = Vec::new();
let mut saw_extm3u = false;
let mut pending_stream_inf: Option<PendingStreamInf> = None;
let mut independent_segments = false;
let mut start: Option<StartPoint> = None;
let mut defines: Vec<Define> = Vec::new();
let mut session_data: Vec<SessionData> = Vec::new();
let mut session_keys: Vec<SessionKey> = Vec::new();
let mut content_steering: Option<ContentSteering> = None;
for (idx, raw_line) in input.lines().enumerate() {
let line_no = idx + 1;
let mut line = raw_line.trim_end_matches('\r');
if line_no == 1 {
line = line.strip_prefix('\u{feff}').unwrap_or(line);
}
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "#EXTM3U" {
saw_extm3u = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
version = parse_decimal(rest, line_no, line, "EXT-X-VERSION")?;
} else if let Some(rest) = line.strip_prefix("#EXT-X-STREAM-INF:") {
let attrs = parse_attr_list(rest);
let bandwidth_str =
require_attr(&attrs, "BANDWIDTH", line_no, line, "EXT-X-STREAM-INF")?;
let bandwidth = parse_decimal(&bandwidth_str, line_no, line, "BANDWIDTH")?;
let codecs = attrs.get("CODECS").cloned().unwrap_or_default();
let resolution = match attrs.get("RESOLUTION") {
Some(v) => Some(parse_resolution(v, line_no, line)?),
None => None,
};
let extra_attrs =
filter_extra_attrs(&attrs, &["BANDWIDTH", "CODECS", "RESOLUTION"]);
pending_stream_inf = Some((bandwidth, codecs, resolution, extra_attrs));
} else if let Some(rest) = line.strip_prefix("#EXT-X-I-FRAME-STREAM-INF:") {
let attrs = parse_attr_list(rest);
let bandwidth_str = require_attr(
&attrs,
"BANDWIDTH",
line_no,
line,
"EXT-X-I-FRAME-STREAM-INF",
)?;
let bandwidth = parse_decimal(&bandwidth_str, line_no, line, "BANDWIDTH")?;
let codecs = attrs.get("CODECS").cloned();
let resolution = match attrs.get("RESOLUTION") {
Some(v) => Some(parse_resolution(v, line_no, line)?),
None => None,
};
let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-I-FRAME-STREAM-INF")?;
let extra_attrs =
filter_extra_attrs(&attrs, &["BANDWIDTH", "CODECS", "RESOLUTION", "URI"]);
iframe_variants.push(IFrameVariant {
bandwidth,
codecs,
resolution,
uri,
extra_attrs,
});
} else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
independent_segments = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
start = Some(parse_start(rest, line_no, line)?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
defines.push(parse_define(rest, line_no, line)?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-DATA:") {
session_data.push(parse_session_data(rest, line_no, line)?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-KEY:") {
session_keys.push(parse_session_key(rest, line_no, line)?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-CONTENT-STEERING:") {
let attrs = parse_attr_list(rest);
let server_uri = require_attr(
&attrs,
"SERVER-URI",
line_no,
line,
"EXT-X-CONTENT-STEERING",
)?;
let pathway_id = attrs.get("PATHWAY-ID").cloned();
let extra_attrs = filter_extra_attrs(&attrs, &["SERVER-URI", "PATHWAY-ID"]);
content_steering = Some(ContentSteering {
server_uri,
pathway_id,
extra_attrs,
});
} else if let Some(rest) = line.strip_prefix("#EXT") {
let _ = rest;
extra_tags.push(line.to_string());
} else if line.starts_with('#') {
} else {
let (bandwidth, codecs, resolution, extra_attrs) =
pending_stream_inf.take().ok_or_else(|| Error::HlsParse {
line_no,
line: line.to_string(),
reason: "variant URI with no preceding #EXT-X-STREAM-INF".to_string(),
})?;
variants.push(Variant {
bandwidth,
codecs,
resolution,
uri: line.to_string(),
extra_attrs,
});
}
}
if !saw_extm3u {
return Err(Error::HlsParse {
line_no: 1,
line: String::new(),
reason: "missing #EXTM3U header".to_string(),
});
}
Ok(MasterPlaylist {
version,
variants,
iframe_variants,
extra_tags,
independent_segments,
start,
defines,
session_data,
session_keys,
content_steering,
})
}
pub fn computed_version(&self) -> Option<u8> {
let mut v = scan_tag_lines_for_version(&self.extra_tags);
if self
.defines
.iter()
.any(|d| matches!(d, Define::QueryParam { .. }))
{
bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
}
if self
.extra_tags
.iter()
.any(|t| contains_variable_substitution(t))
|| self
.variants
.iter()
.any(|var| contains_variable_substitution(&var.uri))
|| self
.iframe_variants
.iter()
.any(|iv| contains_variable_substitution(&iv.uri))
|| self.master_playlist_typed_strings_use_substitution()
{
bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
}
if any_typed_req_attr(
self.start.as_ref(),
&self.defines,
&self.session_data,
&self.session_keys,
self.content_steering.as_ref(),
&self.variants,
&self.iframe_variants,
) {
bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
}
v
}
fn master_playlist_typed_strings_use_substitution(&self) -> bool {
let define_values = self.defines.iter().filter_map(|d| match d {
Define::Name { value, .. } => Some(value),
_ => None,
});
let session_data_strings = self.session_data.iter().map(|sd| match &sd.content {
SessionDataContent::Value(v) => v,
SessionDataContent::Uri { uri, .. } => uri,
});
let session_key_uris = self.session_keys.iter().filter_map(|k| k.uri.as_ref());
let steering_uris = self.content_steering.iter().map(|cs| &cs.server_uri);
define_values
.chain(session_data_strings)
.chain(session_key_uris)
.chain(steering_uris)
.any(|s| contains_variable_substitution(s))
}
fn effective_version(&self) -> Option<u8> {
effective_version(self.version, self.computed_version())
}
}
fn parse_resolution(v: &str, line_no: usize, line: &str) -> Result<(u32, u32)> {
let mut split = v.splitn(2, 'x');
let w = split.next().unwrap_or("");
let h = split.next().ok_or_else(|| Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("RESOLUTION value {v:?} is not of the form <width>x<height>"),
})?;
let width = parse_decimal(w, line_no, line, "RESOLUTION width")?;
let height = parse_decimal(h, line_no, line, "RESOLUTION height")?;
Ok((width, height))
}
pub fn mark_init_discontinuities(entries: &mut [(&[u8], &mut MediaSegment)]) {
if entries.len() < 2 {
return;
}
for i in 1..entries.len() {
let (head, tail) = entries.split_at_mut(i);
let prev_init: &[u8] = head[i - 1].0;
let cur = &mut tail[0];
if cur.0 != prev_init {
cur.1.discontinuous = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seg(uri: &str, duration: f64) -> MediaSegment {
MediaSegment {
uri: uri.into(),
duration,
discontinuous: false,
parts: vec![],
..Default::default()
}
}
fn seg_disc(uri: &str, duration: f64) -> MediaSegment {
MediaSegment {
uri: uri.into(),
duration,
discontinuous: true,
parts: vec![],
..Default::default()
}
}
fn playlist(segments: Vec<MediaSegment>) -> MediaPlaylist {
MediaPlaylist {
version: 3,
target_duration: 10,
media_sequence: 0,
discontinuity_sequence: 0,
segments,
endlist: true,
extra_tags: vec![],
low_latency: None,
iframes_only: false,
open_segment: None,
..Default::default()
}
}
#[test]
fn media_playlist_basic() {
let pl = MediaPlaylist {
version: 3,
target_duration: 10,
media_sequence: 0,
discontinuity_sequence: 0,
segments: vec![
seg("seg0.m4s", 9.009),
seg("seg1.m4s", 9.009),
seg("seg2.m4s", 3.003),
],
endlist: true,
extra_tags: vec![
"#EXT-X-DATERANGE:ID=\"ad-1\",START-DATE=\"2024-01-01T00:00:00.000Z\",DURATION=15.0"
.into(),
],
low_latency: None,
iframes_only: false,
open_segment: None,
..Default::default()
};
let out = pl.to_m3u8();
assert!(out.starts_with("#EXTM3U\n"));
assert!(out.contains("#EXT-X-TARGETDURATION:10\n"));
assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:0\n"));
assert_eq!(out.matches("#EXTINF:").count(), 3);
assert!(out.ends_with("#EXT-X-ENDLIST\n"));
assert!(out.contains("#EXT-X-DATERANGE:ID=\"ad-1\""));
assert!(!out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"));
}
#[test]
fn media_playlist_no_endlist() {
let pl = MediaPlaylist {
version: 7,
target_duration: 6,
media_sequence: 42,
discontinuity_sequence: 0,
segments: vec![seg("seg.m4s", 6.000)],
endlist: false,
extra_tags: vec![],
low_latency: None,
iframes_only: false,
open_segment: None,
..Default::default()
};
let out = pl.to_m3u8();
assert!(out.starts_with("#EXTM3U\n"));
assert!(out.contains("#EXT-X-VERSION:7\n"));
assert!(!out.contains("#EXT-X-ENDLIST"));
}
#[test]
fn master_playlist_basic() {
let pl = MasterPlaylist {
version: 6,
variants: vec![
Variant {
bandwidth: 300_000,
codecs: "avc1.64001e,mp4a.40.2".into(),
resolution: Some((640, 360)),
uri: "v300/index.m3u8".into(),
..Default::default()
},
Variant {
bandwidth: 800_000,
codecs: "avc1.640028,mp4a.40.2".into(),
resolution: Some((1280, 720)),
uri: "v800/index.m3u8".into(),
..Default::default()
},
],
iframe_variants: vec![],
..Default::default()
};
let out = pl.to_m3u8();
assert!(out.starts_with("#EXTM3U\n"));
assert_eq!(out.matches("#EXT-X-STREAM-INF:").count(), 2);
assert!(out.contains("v300/index.m3u8"));
assert!(out.contains("v800/index.m3u8"));
assert!(out.contains("RESOLUTION=640x360"));
assert!(out.contains("RESOLUTION=1280x720"));
}
#[test]
fn master_playlist_no_resolution() {
let pl = MasterPlaylist {
version: 6,
variants: vec![Variant {
bandwidth: 1_000_000,
codecs: "avc1.640028".into(),
resolution: None,
uri: "v1k/index.m3u8".into(),
extra_attrs: Vec::new(),
}],
iframe_variants: vec![],
..Default::default()
};
let out = pl.to_m3u8();
assert!(!out.contains("RESOLUTION"));
assert!(out.contains("#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.640028\""));
}
#[test]
fn extinf_three_decimals() {
let pl = playlist(vec![seg("s.m4s", 9.009)]);
let out = pl.to_m3u8();
assert!(out.contains("#EXTINF:9.009,\n"), "{out}");
}
#[test]
fn integral_extinf_renders_as_an_integer_and_needs_no_version() {
let pl = MediaPlaylist {
version: 0,
..playlist(vec![seg("s.m4s", 9.0)])
};
let out = pl.to_m3u8();
assert!(out.contains("#EXTINF:9,\n"), "{out}");
assert!(!out.contains("9.000"), "{out}");
assert_eq!(
pl.computed_version(),
None,
"a playlist with no floating-point EXTINF trips no §8 row: {out}"
);
assert!(!out.contains("#EXT-X-VERSION"), "{out}");
let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
assert_eq!(reparsed.segments[0].duration, 9.0);
}
#[test]
fn extinf_rendering_and_version_derivation_never_diverge() {
for (duration, expected_text, expected_version) in [
(4.0_f64, "#EXTINF:4,", None),
(4.004, "#EXTINF:4.004,", Some(3)),
(9.9766, "#EXTINF:9.9766,", Some(3)), (4.0004, "#EXTINF:4.0004,", Some(3)), ] {
let pl = playlist(vec![seg("s.m4s", duration)]);
let out = pl.to_m3u8();
assert!(out.contains(expected_text), "{duration}: {out}");
assert_eq!(pl.computed_version(), expected_version, "{duration}: {out}");
assert_eq!(
out.contains(expected_text) && expected_text.contains('.'),
expected_version == Some(3),
"{duration}: rendered text and §8 row 3 must agree: {out}"
);
let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
assert_eq!(reparsed.segments[0].duration, duration, "{out}");
}
}
#[test]
fn sub_millisecond_durations_survive_rendering() {
let pl = playlist(vec![seg("main.ts", 9.9766)]);
let out = pl.to_m3u8();
assert!(
out.contains("#EXTINF:9.9766,\n"),
"EXTINF must not be truncated to 3 decimals:\n{out}"
);
assert_eq!(
MediaPlaylist::parse(&out).unwrap().segments[0].duration,
9.9766,
"duration must survive a round trip bit-exactly"
);
assert_eq!(format_secs(0.334), "0.334");
assert_eq!(format_secs(1.5), "1.5");
assert_eq!(format_secs(6.0), "6");
assert_eq!(format_secs(2.00004), "2.00004");
assert_eq!(format_secs(4.00008), "4.00008");
assert_eq!(format_signed_secs(-10.5), "-10.5");
assert_eq!(format_signed_secs(-2.00004), "-2.00004");
}
#[test]
fn round_trip_rfc_9_11_sub_millisecond_part_durations() {
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
segments: vec![MediaSegment {
uri: "fileSequence271.mp4".into(),
duration: 4.00008,
parts: vec![
PartSpec {
uri: "filePart271.0.mp4".into(),
duration: 2.00004,
independent: true,
..Default::default()
},
PartSpec {
uri: "filePart271.1.mp4".into(),
duration: 0.50001,
..Default::default()
},
],
..Default::default()
}],
low_latency: Some(LowLatencyConfig {
part_target: 2.00002,
part_hold_back: 6.00006,
..Default::default()
}),
..Default::default()
};
let text = pl.to_m3u8();
assert!(text.contains("#EXTINF:4.00008,"), "{text}");
assert!(text.contains("DURATION=2.00004"), "{text}");
assert!(text.contains("DURATION=0.50001"), "{text}");
assert!(text.contains("PART-TARGET=2.00002"), "{text}");
assert_eq!(
MediaPlaylist::parse(&text).expect("must parse"),
pl,
"sub-ms LL-HLS durations must round-trip:\n{text}"
);
}
#[test]
fn discontinuity_tag_emitted_before_extinf() {
let pl = playlist(vec![
seg("s0.m4s", 5.0),
seg_disc("s1.m4s", 5.0),
seg("s2.m4s", 5.0),
]);
let out = pl.to_m3u8();
assert_eq!(out.matches("#EXT-X-DISCONTINUITY\n").count(), 1);
let disc_pos = out.find("#EXT-X-DISCONTINUITY\n").unwrap();
let extinf_pos = out.find("#EXTINF:5.000,\n#s1.m4s\n").unwrap_or_else(|| {
let s1_pos = out.find("s1.m4s\n").unwrap();
out[..s1_pos].rfind("#EXTINF:").unwrap()
});
assert!(
disc_pos < extinf_pos,
"#EXT-X-DISCONTINUITY must appear before #EXTINF of s1"
);
let tag_end = disc_pos + "#EXT-X-DISCONTINUITY\n".len();
assert!(
out[tag_end..].starts_with("#EXTINF:"),
"#EXT-X-DISCONTINUITY must be immediately before #EXTINF, got: {:?}",
&out[tag_end..tag_end + 20]
);
}
#[test]
fn no_discontinuity_tag_when_all_continuous() {
let pl = playlist(vec![
seg("s0.m4s", 5.0),
seg("s1.m4s", 5.0),
seg("s2.m4s", 5.0),
]);
let out = pl.to_m3u8();
assert!(
!out.contains("#EXT-X-DISCONTINUITY\n"),
"no tag when all segments are continuous"
);
}
#[test]
fn discontinuity_sequence_emitted_when_nonzero() {
let pl = MediaPlaylist {
version: 3,
target_duration: 6,
media_sequence: 5,
discontinuity_sequence: 2,
segments: vec![seg("s5.m4s", 6.0)],
endlist: false,
extra_tags: vec![],
low_latency: None,
iframes_only: false,
open_segment: None,
..Default::default()
};
let out = pl.to_m3u8();
assert!(
out.contains("#EXT-X-DISCONTINUITY-SEQUENCE:2\n"),
"header must be present when n>0"
);
}
#[test]
fn discontinuity_sequence_absent_when_zero() {
let pl = playlist(vec![seg("s0.m4s", 6.0)]);
let out = pl.to_m3u8();
assert!(
!out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
"header must be absent when n==0"
);
}
fn ll_config() -> LowLatencyConfig {
LowLatencyConfig {
part_target: 0.5,
part_hold_back: 1.5,
preload_hint_part: None,
..Default::default()
}
}
#[test]
fn ll_hls_renders_server_control_part_inf_and_parts() {
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 0,
discontinuity_sequence: 0,
segments: vec![MediaSegment {
uri: "seg-1-4.m4s".into(),
duration: 4.0,
discontinuous: false,
parts: vec![PartSpec {
uri: "part-1-1.m4s".into(),
duration: 0.5,
independent: true,
..Default::default()
}],
..Default::default()
}],
endlist: false,
extra_tags: vec![],
low_latency: Some(ll_config()),
iframes_only: false,
open_segment: None,
..Default::default()
};
let out = pl.to_m3u8();
assert!(out.contains("#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES"));
assert!(out.contains("#EXT-X-PART-INF:PART-TARGET="));
assert!(out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.m4s\""));
}
#[test]
fn open_segment_renders_parts_without_extinf() {
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 0,
discontinuity_sequence: 0,
segments: vec![MediaSegment {
uri: "seg-1-4.m4s".into(),
duration: 4.0,
discontinuous: false,
parts: vec![],
..Default::default()
}],
endlist: false,
extra_tags: vec![],
low_latency: Some(ll_config()),
iframes_only: false,
open_segment: Some(OpenSegment::new(vec![PartSpec {
uri: "part-1-5.0.m4s".into(),
duration: 0.5,
independent: true,
..Default::default()
}])),
..Default::default()
};
let out = pl.to_m3u8();
assert!(
out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
"open segment's part must render:\n{out}"
);
assert!(out.contains("#EXTINF:4,\n"), "{out}");
assert!(out.contains("seg-1-4.m4s"));
assert!(
!out.contains("#EXTINF:0.500,\npart-1-5.0.m4s"),
"open segment must not be rendered as a closed #EXTINF segment:\n{out}"
);
assert_eq!(
out.matches("#EXTINF:").count(),
1,
"only closed segments should have #EXTINF lines; open segment must not:\n{out}"
);
let lines: Vec<&str> = out.lines().collect();
for (i, line) in lines.iter().enumerate() {
if *line == "part-1-5.0.m4s" {
panic!("open part URI must not appear on its own URI line: {out}");
}
if line.starts_with("#EXTINF") && i + 1 < lines.len() {
assert_ne!(
lines[i + 1],
"part-1-5.0.m4s",
"open part URI must not follow an #EXTINF line:\n{out}"
);
}
}
}
#[test]
fn open_segment_not_rendered_without_low_latency() {
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 0,
discontinuity_sequence: 0,
segments: vec![seg("seg-1-4.m4s", 4.0)],
endlist: false,
extra_tags: vec![],
low_latency: None,
iframes_only: false,
open_segment: Some(OpenSegment::new(vec![PartSpec {
uri: "part-1-5.0.m4s".into(),
duration: 0.5,
independent: true,
..Default::default()
}])),
..Default::default()
};
let out = pl.to_m3u8();
assert!(
!out.contains("part-1-5.0.m4s"),
"open segment parts must not render without low_latency:\n{out}"
);
assert!(!out.contains("#EXT-X-PART:"));
}
#[test]
fn preload_hint_rendered_from_low_latency() {
let mut ll = ll_config();
ll.preload_hint_part = Some("part-1-5.1.m4s".into());
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 0,
discontinuity_sequence: 0,
segments: vec![seg("seg-1-4.m4s", 4.0)],
endlist: false,
extra_tags: vec![],
low_latency: Some(ll),
iframes_only: false,
open_segment: None,
..Default::default()
};
let out = pl.to_m3u8();
assert!(out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""));
}
#[test]
fn open_segment_parts_precede_preload_hint() {
let mut ll = ll_config();
ll.preload_hint_part = Some("part-1-5.1.m4s".into());
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 0,
discontinuity_sequence: 0,
segments: vec![MediaSegment {
uri: "seg-1-4.m4s".into(),
duration: 4.0,
discontinuous: false,
parts: vec![],
..Default::default()
}],
endlist: false,
extra_tags: vec![],
low_latency: Some(ll),
iframes_only: false,
open_segment: Some(OpenSegment::new(vec![PartSpec {
uri: "part-1-5.0.m4s".into(),
duration: 0.5,
independent: true,
..Default::default()
}])),
..Default::default()
};
let out = pl.to_m3u8();
assert!(
out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
"open-segment part must be present:\n{out}"
);
assert!(
out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""),
"preload-hint must be present:\n{out}"
);
let part_pos = out
.find("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES")
.expect("open-segment part line not found");
let preload_pos = out
.find("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\"")
.expect("preload-hint line not found");
assert!(
part_pos < preload_pos,
"open-segment #EXT-X-PART must precede #EXT-X-PRELOAD-HINT:\npart at {}, preload at {}\noutput:\n{out}",
part_pos,
preload_pos
);
}
#[test]
fn open_segment_inherits_map_when_no_segment_has_closed_yet() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:9\n\
#EXT-X-TARGETDURATION:4\n\
#EXT-X-MEDIA-SEQUENCE:1\n\
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
#EXT-X-PART-INF:PART-TARGET=0.5\n\
#EXT-X-MAP:URI=\"init-1.mp4\"\n\
#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.0.m4s\",INDEPENDENT=YES\n\
#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-1.1.m4s\"\n";
let pl = MediaPlaylist::parse(text).expect("must parse");
assert!(pl.segments.is_empty(), "no segment has closed yet");
let open = pl.open_segment.as_ref().expect("one open segment");
assert_eq!(
open.map,
Some(MapTag {
uri: "init-1.mp4".into(),
byte_range: None,
extra_attrs: Vec::new(),
}),
"the open segment must inherit the EXT-X-MAP that precedes it, \
even though no segment has closed yet"
);
}
#[test]
fn round_trip_open_segment_only_with_map() {
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 1,
discontinuity_sequence: 0,
segments: vec![],
open_segment: Some(
OpenSegment::new(vec![PartSpec {
uri: "part-1-1.0.m4s".into(),
duration: 0.5,
independent: true,
..Default::default()
}])
.with_map(MapTag {
uri: "init-1.mp4".into(),
byte_range: None,
extra_attrs: Vec::new(),
}),
),
endlist: false,
extra_tags: vec![],
low_latency: Some(ll_config()),
iframes_only: false,
..Default::default()
};
let text = pl.to_m3u8();
assert!(
text.contains("#EXT-X-MAP:URI=\"init-1.mp4\""),
"renderer must emit EXT-X-MAP for a bare open segment:\n{text}"
);
let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
}
fn ll_config_full() -> LowLatencyConfig {
LowLatencyConfig {
part_target: 0.5,
part_hold_back: 1.5, preload_hint_part: Some("part-9.2.m4s".into()),
preload_hint_type: PreloadHintType::Part,
preload_hint_byte_range_start: Some(0),
preload_hint_byte_range_length: Some(1000),
can_skip_until: Some(24.0),
can_block_reload: true,
hold_back: None,
can_skip_dateranges: false,
..Default::default()
}
}
#[test]
fn round_trip_live_ll_playlist_with_parts_preload_and_server_control() {
let map = MapTag {
uri: "init.mp4".into(),
byte_range: Some(ByteRange {
length: 800,
offset: Some(0),
}),
extra_attrs: Vec::new(),
};
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 100,
discontinuity_sequence: 0,
segments: vec![MediaSegment {
uri: "seg-9.m4s".into(),
duration: 4.0,
discontinuous: false,
parts: vec![
PartSpec {
uri: "part-9.0.m4s".into(),
duration: 0.5,
independent: true,
byte_range: None,
gap: false,
..Default::default()
},
PartSpec {
uri: "part-9.1.m4s".into(),
duration: 0.5,
independent: false,
byte_range: Some(ByteRange {
length: 500,
offset: Some(1000),
}),
gap: false,
..Default::default()
},
],
byte_range: None,
map: Some(map.clone()),
..Default::default()
}],
open_segment: Some(
OpenSegment::new(vec![PartSpec {
uri: "part-10.0.m4s".into(),
duration: 0.5,
independent: true,
byte_range: None,
gap: true,
extra_attrs: Vec::new(),
}])
.with_map(map.clone()),
),
endlist: false,
extra_tags: vec![],
low_latency: Some(ll_config_full()),
iframes_only: false,
rendition_reports: vec![RenditionReport {
uri: "../audio/playlist.m3u8".into(),
last_msn: 100,
last_part: Some(1),
extra_attrs: Vec::new(),
}],
skip: None,
..Default::default()
};
let text = pl.to_m3u8();
let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
}
#[test]
fn round_trip_vod_playlist_with_byteranges_map_and_endlist() {
let map = MapTag {
uri: "init.mp4".into(),
byte_range: None,
extra_attrs: Vec::new(),
};
let pl = MediaPlaylist {
version: 6,
target_duration: 10,
media_sequence: 0,
discontinuity_sequence: 0,
segments: vec![
MediaSegment {
uri: "media.ts".into(),
duration: 10.0,
discontinuous: false,
parts: vec![],
byte_range: Some(ByteRange {
length: 500_000,
offset: Some(0),
}),
map: Some(map.clone()),
..Default::default()
},
MediaSegment {
uri: "media.ts".into(),
duration: 10.0,
discontinuous: false,
parts: vec![],
byte_range: Some(ByteRange {
length: 500_000,
offset: None,
}),
map: Some(map.clone()),
..Default::default()
},
],
open_segment: None,
endlist: true,
extra_tags: vec![
"#EXT-X-DATERANGE:ID=\"ad-1\",START-DATE=\"2024-01-01T00:00:00.000Z\",DURATION=15.0"
.into(),
],
low_latency: None,
iframes_only: false,
rendition_reports: vec![],
skip: None,
..Default::default()
};
let text = pl.to_m3u8();
assert_eq!(
text.matches("#EXT-X-MAP:").count(),
1,
"identical map on consecutive segments must render once:\n{text}"
);
let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
}
#[test]
fn round_trip_media_playlist_with_new_872_tags() {
let pl = MediaPlaylist {
version: 6,
target_duration: 10,
media_sequence: 0,
discontinuity_sequence: 0,
independent_segments: true,
start: Some(StartPoint {
time_offset: 5.5,
precise: false,
extra_attrs: Vec::new(),
}),
defines: vec![Define::Import {
name: "base".into(),
extra_attrs: Vec::new(),
}],
playlist_type: Some(PlaylistType::Vod),
segments: vec![
MediaSegment {
uri: "seg0.ts".into(),
duration: 10.0,
bitrate: Some(2000),
..Default::default()
},
MediaSegment {
uri: "seg1.ts".into(),
duration: 10.0,
gap: true,
bitrate: Some(2000),
..Default::default()
},
MediaSegment {
uri: "seg2.ts".into(),
duration: 10.0,
bitrate: Some(1800),
..Default::default()
},
],
endlist: true,
..Default::default()
};
let text = pl.to_m3u8();
assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
assert!(text.contains("#EXT-X-DEFINE:IMPORT=\"base\"\n"));
assert!(text.contains("#EXT-X-START:TIME-OFFSET=5.5\n"));
assert!(
!text.contains("PRECISE"),
"PRECISE=NO must be omitted:\n{text}"
);
assert!(text.contains("#EXT-X-PLAYLIST-TYPE:VOD\n"));
assert!(text.contains("#EXT-X-GAP\n"));
assert_eq!(
text.matches("#EXT-X-BITRATE:").count(),
2,
"unchanged bitrate must not re-emit:\n{text}"
);
assert!(text.contains("#EXT-X-BITRATE:2000\n"));
assert!(text.contains("#EXT-X-BITRATE:1800\n"));
let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
}
#[test]
fn parse_rejects_unrecognized_playlist_type() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:3\n\
#EXT-X-TARGETDURATION:6\n\
#EXT-X-PLAYLIST-TYPE:LIVE\n\
#EXTINF:6.000,\n\
s0.m4s\n";
let err = MediaPlaylist::parse(text).expect_err("unrecognized PLAYLIST-TYPE must error");
assert!(matches!(err, Error::HlsParse { .. }));
}
#[test]
fn parse_rejects_define_with_wrong_attribute_count() {
let none = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-DEFINE:VALUE=\"x\"\n\
#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
let err = MediaPlaylist::parse(none).expect_err("DEFINE with none of the three must error");
assert!(matches!(err, Error::HlsParse { .. }));
let both = "#EXTM3U\n#EXT-X-VERSION:3\n\
#EXT-X-DEFINE:NAME=\"a\",VALUE=\"1\",IMPORT=\"b\"\n\
#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
let err = MediaPlaylist::parse(both).expect_err("DEFINE with two of the three must error");
assert!(matches!(err, Error::HlsParse { .. }));
}
#[test]
fn parse_rejects_session_data_with_wrong_content_count() {
let neither = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\"\n";
let err = MasterPlaylist::parse(neither)
.expect_err("SESSION-DATA with neither VALUE nor URI must error");
assert!(matches!(err, Error::HlsParse { .. }));
let both = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\",VALUE=\"v\",URI=\"u\"\n";
let err = MasterPlaylist::parse(both)
.expect_err("SESSION-DATA with both VALUE and URI must error");
assert!(matches!(err, Error::HlsParse { .. }));
}
#[test]
fn parse_rejects_malformed_session_key_iv() {
let text = "#EXTM3U\n\
#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"k\",IV=0xnotahexvalue\n";
let err = MasterPlaylist::parse(text).expect_err("malformed IV must error");
assert!(matches!(err, Error::HlsParse { .. }));
}
#[test]
fn round_trip_multivariant_playlist() {
let pl = MasterPlaylist {
version: 7,
variants: vec![
Variant {
bandwidth: 300_000,
codecs: "avc1.64001e,mp4a.40.2".into(),
resolution: Some((640, 360)),
uri: "v300/index.m3u8".into(),
..Default::default()
},
Variant {
bandwidth: 800_000,
codecs: "avc1.640028,mp4a.40.2".into(),
resolution: Some((1280, 720)),
uri: "v800/index.m3u8".into(),
..Default::default()
},
],
iframe_variants: vec![IFrameVariant {
bandwidth: 50_000,
codecs: Some("avc1.64001e".into()),
resolution: Some((640, 360)),
uri: "v300/iframe.m3u8".into(),
extra_attrs: Vec::new(),
}],
..Default::default()
};
let text = pl.to_m3u8();
let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
}
#[test]
fn round_trip_multivariant_playlist_with_new_872_tags() {
let pl = MasterPlaylist {
version: 11,
independent_segments: true,
start: Some(StartPoint {
time_offset: -10.5,
precise: true,
extra_attrs: Vec::new(),
}),
defines: vec![
Define::Name {
name: "base".into(),
value: "https://cdn.example.com/video12".into(),
extra_attrs: Vec::new(),
},
Define::QueryParam {
name: "token".into(),
extra_attrs: Vec::new(),
},
],
session_data: vec![
SessionData {
data_id: "com.example.lyrics".into(),
content: SessionDataContent::Uri {
uri: "lyrics.json".into(),
format: SessionDataFormat::Json,
},
language: None,
extra_attrs: Vec::new(),
},
SessionData {
data_id: "com.example.title".into(),
content: SessionDataContent::Value("This is an example".into()),
language: Some("en".into()),
extra_attrs: Vec::new(),
},
],
session_keys: vec![
SessionKey {
method: EncryptionMethod::Aes128,
uri: Some("https://priv.example.com/key.php?r=52".into()),
iv: None,
keyformat: Some("identity".into()),
keyformatversions: Some("1".into()),
extra_attrs: Vec::new(),
},
SessionKey {
method: EncryptionMethod::SampleAesCtr,
uri: Some("skd://key2".into()),
iv: Some([
0x9c, 0x7d, 0xb8, 0x77, 0x85, 0x70, 0xd0, 0x5c, 0x3a, 0x5e, 0x3d, 0x2c,
0x8a, 0xe5, 0x5e, 0x46,
]),
keyformat: None,
keyformatversions: None,
extra_attrs: Vec::new(),
},
],
content_steering: Some(ContentSteering {
server_uri: "/steering?video=00012".into(),
pathway_id: Some("CDN-A".into()),
extra_attrs: Vec::new(),
}),
variants: vec![Variant {
bandwidth: 1_280_000,
codecs: "avc1.64001e,mp4a.40.2".into(),
resolution: Some((640, 360)),
uri: "low/index.m3u8".into(),
extra_attrs: Vec::new(),
}],
iframe_variants: vec![],
extra_tags: vec![],
};
assert_eq!(
pl.computed_version(),
Some(11),
"EXT-X-DEFINE with QUERYPARAM must derive §8 row 11 from the \
typed `defines` field (issue #872 + #880 integration)"
);
let text = pl.to_m3u8();
assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
assert!(text.contains("#EXT-X-START:TIME-OFFSET=-10.5,PRECISE=YES\n"));
assert!(
text.contains(
"#EXT-X-DEFINE:NAME=\"base\",VALUE=\"https://cdn.example.com/video12\"\n"
)
);
assert!(text.contains("#EXT-X-DEFINE:QUERYPARAM=\"token\"\n"));
assert!(
text.contains(
"#EXT-X-SESSION-DATA:DATA-ID=\"com.example.lyrics\",URI=\"lyrics.json\"\n"
)
);
assert!(text.contains(
"#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,URI=\"skd://key2\",IV=0x9c7db8778570d05c3a5e3d2c8ae55e46\n"
));
assert!(text.contains(
"#EXT-X-CONTENT-STEERING:SERVER-URI=\"/steering?video=00012\",PATHWAY-ID=\"CDN-A\"\n"
));
let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
}
#[test]
fn real_world_sample_ll_playlist_from_rfc8216bis_appendix() {
let text = "\
#EXTM3U
#EXT-X-VERSION:9
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:266
#EXT-X-PART-INF:PART-TARGET=2.00002
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=6.00006
#EXTINF:4.00008,
fileSequence268.mp4
#EXTINF:4.00008,
fileSequence269.mp4
#EXTINF:4.00008,
fileSequence270.mp4
#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart271.0.mp4\"
#EXT-X-PART:DURATION=2.00004,URI=\"filePart271.1.mp4\"
#EXTINF:4.00008,
fileSequence271.mp4
#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart272.0.mp4\"
#EXT-X-PART:DURATION=0.50001,URI=\"filePart272.1.mp4\"
#EXTINF:2.50005,
fileSequence272.mp4
#EXT-X-DISCONTINUITY
#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll273.0.mp4\"
#EXT-X-PART:DURATION=2.00004,URI=\"midRoll273.1.mp4\"
#EXTINF:4.00008,
midRoll273.mp4
#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll274.0.mp4\"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"midRoll274.1.mp4\"
#EXT-X-RENDITION-REPORT:URI=\"/1M/LL-HLS.m3u8\",LAST-MSN=274,LAST-PART=1
";
let pl = MediaPlaylist::parse(text).expect("real-world LL sample must parse");
assert_eq!(pl.version, 9);
assert_eq!(pl.target_duration, 4);
assert_eq!(pl.media_sequence, 266);
assert_eq!(pl.segments.len(), 6, "{:?}", pl.segments);
assert_eq!(pl.segments[4].uri, "fileSequence272.mp4");
assert_eq!(pl.segments[4].parts.len(), 2);
assert!(pl.segments[4].parts[0].independent);
assert!(!pl.segments[4].parts[1].independent);
assert_eq!(pl.segments[5].uri, "midRoll273.mp4");
assert!(
pl.segments[5].discontinuous,
"midRoll273 follows #EXT-X-DISCONTINUITY"
);
let open = pl.open_segment.as_ref().expect("midRoll274 is open");
assert_eq!(open.parts.len(), 1);
assert_eq!(open.parts[0].uri, "midRoll274.0.mp4");
let ll = pl.low_latency.as_ref().expect("LL config must be present");
assert_eq!(ll.preload_hint_part.as_deref(), Some("midRoll274.1.mp4"));
assert!(
ll.can_block_reload,
"CAN-BLOCK-RELOAD=YES in the fixture must parse to true"
);
assert_eq!(pl.rendition_reports.len(), 1);
assert_eq!(pl.rendition_reports[0].uri, "/1M/LL-HLS.m3u8");
assert_eq!(pl.rendition_reports[0].last_msn, 274);
assert_eq!(pl.rendition_reports[0].last_part, Some(1));
}
#[test]
fn real_world_sample_delta_update_with_skip() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:9\n\
#EXT-X-TARGETDURATION:4\n\
#EXT-X-MEDIA-SEQUENCE:1000\n\
#EXT-X-PART-INF:PART-TARGET=0.5\n\
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-UNTIL=24.0,PART-HOLD-BACK=1.5\n\
#EXT-X-SKIP:SKIPPED-SEGMENTS=996,RECENTLY-REMOVED-DATERANGES=\"ad-1\tad-2\"\n\
#EXTINF:4.00000,\n\
fileSequence1996.mp4\n\
#EXTINF:4.00000,\n\
fileSequence1997.mp4\n";
let pl = MediaPlaylist::parse(text).expect("delta update sample must parse");
assert_eq!(pl.media_sequence, 1000);
assert_eq!(pl.segments.len(), 2);
assert_eq!(pl.segments[0].uri, "fileSequence1996.mp4");
let skip = pl.skip.as_ref().expect("EXT-X-SKIP must be captured");
assert_eq!(skip.skipped_segments, 996);
assert_eq!(skip.recently_removed_daterange_ids, vec!["ad-1", "ad-2"]);
let ll = pl.low_latency.as_ref().expect("LL config must be present");
assert_eq!(ll.can_skip_until, Some(24.0));
assert!(!pl.endlist);
}
#[test]
fn real_world_sample_can_block_reload_no_is_parsed_not_inferred() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:9\n\
#EXT-X-TARGETDURATION:4\n\
#EXT-X-MEDIA-SEQUENCE:0\n\
#EXT-X-PART-INF:PART-TARGET=0.5\n\
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=NO,PART-HOLD-BACK=1.5\n\
#EXTINF:4.00000,\n\
seg0.mp4\n";
let pl = MediaPlaylist::parse(text).expect("must parse");
let ll = pl
.low_latency
.as_ref()
.expect("LL config must be present (PART-INF seen)");
assert!(
!ll.can_block_reload,
"CAN-BLOCK-RELOAD=NO must not be inferred as true just because low_latency is Some"
);
}
#[test]
fn parse_defaults_can_block_reload_false_when_attribute_absent() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:9\n\
#EXT-X-TARGETDURATION:4\n\
#EXT-X-MEDIA-SEQUENCE:0\n\
#EXT-X-PART-INF:PART-TARGET=0.5\n\
#EXTINF:4.00000,\n\
seg0.mp4\n";
let pl = MediaPlaylist::parse(text).expect("must parse");
let ll = pl
.low_latency
.as_ref()
.expect("LL config must be present (PART-INF seen)");
assert!(
!ll.can_block_reload,
"absent CAN-BLOCK-RELOAD/SERVER-CONTROL must default to false, not true"
);
}
#[test]
fn round_trip_can_block_reload_no() {
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 0,
discontinuity_sequence: 0,
segments: vec![MediaSegment {
uri: "seg0.mp4".into(),
duration: 4.0,
..Default::default()
}],
low_latency: Some(LowLatencyConfig {
part_target: 0.5,
part_hold_back: 1.5,
can_block_reload: false,
..Default::default()
}),
..Default::default()
};
let text = pl.to_m3u8();
assert!(
text.contains("CAN-BLOCK-RELOAD=NO"),
"renderer must emit the actual value:\n{text}"
);
let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
}
#[test]
fn parse_ignores_unrecognized_tag_by_preserving_it_into_extra_tags() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:3\n\
#EXT-X-TARGETDURATION:6\n\
#EXT-X-MEDIA-SEQUENCE:0\n\
#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:00.000Z\n\
#EXTINF:6.000,\n\
s0.m4s\n\
#EXT-X-ENDLIST\n";
let pl = MediaPlaylist::parse(text).expect("unrecognized tag must not error");
assert!(
pl.extra_tags
.iter()
.any(|t| t.starts_with("#EXT-X-PROGRAM-DATE-TIME:")),
"unrecognized tag must be preserved verbatim, not dropped: {:?}",
pl.extra_tags
);
}
#[test]
fn parse_rejects_missing_targetduration() {
let text = "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:6.000,\ns0.m4s\n";
let err = MediaPlaylist::parse(text).expect_err("missing TARGETDURATION must error");
assert!(matches!(err, Error::HlsParse { .. }));
}
#[test]
fn parse_rejects_malformed_part_missing_duration() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:9\n\
#EXT-X-TARGETDURATION:4\n\
#EXT-X-PART-INF:PART-TARGET=0.5\n\
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
#EXT-X-PART:URI=\"part.m4s\"\n\
#EXTINF:4.000,\n\
seg.m4s\n";
let err = MediaPlaylist::parse(text).expect_err("EXT-X-PART without DURATION must error");
let Error::HlsParse { reason, .. } = err;
assert!(reason.contains("DURATION"), "{reason}");
}
#[test]
fn parse_rejects_variant_uri_with_no_preceding_stream_inf() {
let text = "#EXTM3U\n#EXT-X-VERSION:6\nv300/index.m3u8\n";
let err = MasterPlaylist::parse(text).expect_err("orphan variant URI must error");
assert!(matches!(err, Error::HlsParse { .. }));
}
#[test]
fn parse_master_playlist_preserves_ext_x_media_into_extra_tags() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:7\n\
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\",NAME=\"English\",DEFAULT=YES,URI=\"eng.m3u8\"\n\
#EXT-X-STREAM-INF:BANDWIDTH=300000,CODECS=\"avc1.64001e,mp4a.40.2\"\n\
v300/index.m3u8\n";
let pl = MasterPlaylist::parse(text).expect("EXT-X-MEDIA must be ignored, not error");
assert_eq!(pl.variants.len(), 1);
assert_eq!(pl.variants[0].uri, "v300/index.m3u8");
assert!(
pl.extra_tags.iter().any(|t| t.starts_with("#EXT-X-MEDIA:")),
"unrecognized tag must be preserved verbatim, not dropped: {:?}",
pl.extra_tags
);
}
fn base_media_playlist() -> MediaPlaylist {
MediaPlaylist {
version: 0,
target_duration: 6,
media_sequence: 0,
segments: vec![seg("s0.m4s", 6.0)],
endlist: true,
..Default::default()
}
}
fn rendered_version(m3u8: &str) -> Option<u8> {
m3u8.lines()
.find_map(|l| l.strip_prefix("#EXT-X-VERSION:"))
.map(|v| v.parse::<u8>().expect("version must be a valid u8"))
}
#[test]
fn version_row1_no_trigger_omits_the_tag() {
let out = base_media_playlist().to_m3u8();
assert_eq!(rendered_version(&out), None, "no trigger:\n{out}");
}
#[test]
fn version_row2_key_iv_triggers_v2() {
let mut pl = base_media_playlist();
pl.extra_tags = vec![
"#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",IV=0x00000000000000000000000000000001"
.into(),
];
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(2), "{out}");
}
#[test]
fn version_row3_float_extinf_triggers_v3() {
let mut pl = base_media_playlist();
pl.segments = vec![seg("s0.m4s", 6.5)];
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(3), "{out}");
}
#[test]
fn version_row4_byterange_triggers_v4() {
let mut pl = base_media_playlist();
pl.segments[0].byte_range = Some(ByteRange {
length: 1000,
offset: Some(0),
});
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(4), "{out}");
}
#[test]
fn version_row4_iframes_only_triggers_v4() {
let mut pl = base_media_playlist();
pl.iframes_only = true;
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(4), "{out}");
}
#[test]
fn version_row5_sample_aes_triggers_v5() {
let mut pl = base_media_playlist();
pl.extra_tags = vec!["#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"https://k\",KEYID=0x01".into()];
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(5), "{out}");
}
#[test]
fn version_row5_keyformat_triggers_v5() {
let mut pl = base_media_playlist();
pl.extra_tags = vec![
"#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",KEYFORMAT=\"com.example\",\
KEYFORMATVERSIONS=\"1\""
.into(),
];
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(5), "{out}");
}
#[test]
fn version_row5_map_with_iframes_only_triggers_v5_not_v6() {
let mut pl = base_media_playlist();
pl.iframes_only = true;
pl.segments[0].map = Some(MapTag {
uri: "init.mp4".into(),
byte_range: None,
extra_attrs: Vec::new(),
});
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(5), "{out}");
}
#[test]
fn version_row6_map_without_iframes_only_triggers_v6() {
let mut pl = base_media_playlist();
pl.segments[0].map = Some(MapTag {
uri: "init.mp4".into(),
byte_range: None,
extra_attrs: Vec::new(),
});
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(6), "{out}");
}
#[test]
fn version_row7_media_service_instream_id_triggers_v7_multivariant_only() {
let mut pl = MasterPlaylist {
version: 0,
variants: vec![Variant {
bandwidth: 300_000,
codecs: "avc1.64001e".into(),
resolution: None,
uri: "v300/index.m3u8".into(),
extra_attrs: Vec::new(),
}],
iframe_variants: vec![],
extra_tags: vec![],
..Default::default()
};
pl.extra_tags = vec![
"#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID=\"cc\",NAME=\"CC1\",\
INSTREAM-ID=\"SERVICE1\""
.into(),
];
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(7), "{out}");
}
#[test]
fn version_row8_variable_substitution_triggers_v8() {
let mut pl = base_media_playlist();
pl.segments = vec![seg("seg-{$id}.m4s", 6.0)];
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(8), "{out}");
}
#[test]
fn version_row8_variable_substitution_triggers_v8_multivariant() {
let pl = MasterPlaylist {
version: 0,
variants: vec![Variant {
bandwidth: 300_000,
codecs: "avc1.64001e".into(),
resolution: None,
uri: "{$base}/index.m3u8".into(),
extra_attrs: Vec::new(),
}],
iframe_variants: vec![],
extra_tags: vec![],
..Default::default()
};
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(8), "{out}");
}
#[test]
fn version_row9_skip_triggers_v9() {
let mut pl = base_media_playlist();
pl.skip = Some(SkipInfo {
skipped_segments: 5,
recently_removed_daterange_ids: vec![],
..Default::default()
});
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(9), "{out}");
}
#[test]
fn version_row10_skip_replacing_daterange_triggers_v10() {
let mut pl = base_media_playlist();
pl.skip = Some(SkipInfo {
skipped_segments: 5,
recently_removed_daterange_ids: vec!["ad-1".into()],
..Default::default()
});
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(10), "{out}");
}
#[test]
fn version_row11_define_queryparam_triggers_v11() {
let mut pl = base_media_playlist();
pl.extra_tags = vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()];
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(11), "{out}");
}
#[test]
fn version_row11_define_queryparam_triggers_v11_multivariant() {
let pl = MasterPlaylist {
version: 0,
variants: vec![Variant {
bandwidth: 300_000,
codecs: "avc1.64001e".into(),
resolution: None,
uri: "v300/index.m3u8".into(),
..Default::default()
}],
iframe_variants: vec![],
extra_tags: vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()],
..Default::default()
};
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(11), "{out}");
}
#[test]
fn version_row12_req_attribute_triggers_v12() {
let mut pl = base_media_playlist();
pl.extra_tags = vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()];
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(12), "{out}");
}
#[test]
fn version_row12_req_attribute_triggers_v12_multivariant() {
let pl = MasterPlaylist {
version: 0,
variants: vec![Variant {
bandwidth: 300_000,
codecs: "avc1.64001e".into(),
resolution: None,
uri: "v300/index.m3u8".into(),
..Default::default()
}],
iframe_variants: vec![],
extra_tags: vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()],
..Default::default()
};
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(12), "{out}");
}
#[test]
fn version_row13_media_instream_id_non_cc_triggers_v13_multivariant_only() {
let pl = MasterPlaylist {
version: 0,
variants: vec![Variant {
bandwidth: 300_000,
codecs: "avc1.64001e".into(),
resolution: None,
uri: "v300/index.m3u8".into(),
..Default::default()
}],
iframe_variants: vec![],
extra_tags: vec![
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"Eng\",INSTREAM-ID=\"CC1\"".into(),
],
..Default::default()
};
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(13), "{out}");
}
#[test]
fn version_multivariant_no_trigger_omits_the_tag() {
let pl = MasterPlaylist {
version: 0,
variants: vec![Variant {
bandwidth: 300_000,
codecs: "avc1.64001e".into(),
resolution: None,
uri: "v300/index.m3u8".into(),
..Default::default()
}],
iframe_variants: vec![],
extra_tags: vec![],
..Default::default()
};
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), None, "{out}");
}
#[test]
fn named_case_1_fmp4_low_latency_renders_6_not_9() {
let pl = MediaPlaylist {
version: 0, target_duration: 4,
media_sequence: 100,
segments: vec![MediaSegment {
uri: "seg-1-100.m4s".into(),
duration: 4.0,
..Default::default()
}],
open_segment: Some(OpenSegment::new(vec![PartSpec {
uri: "part-1-101.0.m4s".into(),
duration: 0.5,
independent: true,
..Default::default()
}])),
extra_tags: vec!["#EXT-X-MAP:URI=\"init-1.mp4\"".into()],
low_latency: Some(LowLatencyConfig {
part_target: 0.5,
part_hold_back: 1.5,
..Default::default()
}),
iframes_only: false,
..Default::default()
};
let out = pl.to_m3u8();
assert_eq!(
rendered_version(&out),
Some(6),
"fMP4 + low-latency must render 6, not the old hardcoded 9:\n{out}"
);
assert!(!out.contains("#EXT-X-VERSION:9"), "{out}");
}
#[test]
fn named_case_2_classic_mpegts_float_extinf_renders_3() {
let pl = MediaPlaylist {
version: 0,
target_duration: 10,
media_sequence: 0,
segments: vec![
seg("seg0.ts", 9.009),
seg("seg1.ts", 9.009),
seg("seg2.ts", 3.003),
],
endlist: true,
..Default::default()
};
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(3), "{out}");
}
#[test]
fn named_case_3_adding_skip_raises_version_to_9() {
let mut pl = base_media_playlist();
assert_eq!(
rendered_version(&pl.to_m3u8()),
None,
"sanity: no trigger before adding EXT-X-SKIP"
);
pl.skip = Some(SkipInfo {
skipped_segments: 3,
recently_removed_daterange_ids: vec![],
..Default::default()
});
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(9), "{out}");
}
#[test]
fn named_case_4_no_trigger_renders_no_version_tag() {
let out = base_media_playlist().to_m3u8();
assert!(!out.contains("#EXT-X-VERSION"), "{out}");
}
#[test]
fn named_case_5_sample_aes_renders_5() {
let tag = cenc_ext_x_key(CencScheme::Cbcs, &[0xab; 16], "https://k.example/key")
.expect("cbcs must emit an EXT-X-KEY tag");
let mut pl = base_media_playlist();
pl.extra_tags = vec![tag];
let out = pl.to_m3u8();
assert_eq!(rendered_version(&out), Some(5), "{out}");
}
#[test]
fn parse_and_round_trip_can_skip_dateranges_yes() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:9\n\
#EXT-X-TARGETDURATION:4\n\
#EXT-X-PART-INF:PART-TARGET=0.5\n\
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,CAN-SKIP-UNTIL=24.0,CAN-SKIP-DATERANGES=YES\n\
#EXTINF:4.000,\n\
seg0.m4s\n";
let pl = MediaPlaylist::parse(text).expect("parse must succeed");
let ll = pl.low_latency.as_ref().expect("must have low_latency");
assert!(
ll.can_skip_dateranges,
"CAN-SKIP-DATERANGES=YES must parse to true"
);
let round = pl.to_m3u8();
assert!(
round.contains("CAN-SKIP-DATERANGES=YES"),
"render must emit CAN-SKIP-DATERANGES=YES:\n{round}"
);
let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
let re_ll = reparse
.low_latency
.as_ref()
.expect("must have low_latency on reparse");
assert!(
re_ll.can_skip_dateranges,
"CAN-SKIP-DATERANGES=YES must survive round trip"
);
}
#[test]
fn can_skip_dateranges_not_rendered_without_can_skip_until() {
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 0,
segments: vec![],
low_latency: Some(LowLatencyConfig {
part_target: 0.5,
part_hold_back: 1.5,
can_skip_until: None,
can_skip_dateranges: true,
can_block_reload: true,
..Default::default()
}),
..Default::default()
};
let out = pl.to_m3u8();
assert!(
!out.contains("CAN-SKIP-DATERANGES"),
"CAN-SKIP-DATERANGES must NOT render without CAN-SKIP-UNTIL:\n{out}"
);
}
#[test]
fn parse_and_round_trip_hold_back() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:9\n\
#EXT-X-TARGETDURATION:4\n\
#EXT-X-PART-INF:PART-TARGET=0.5\n\
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,HOLD-BACK=12.0\n\
#EXTINF:4.000,\n\
seg0.m4s\n";
let pl = MediaPlaylist::parse(text).expect("parse must succeed");
let ll = pl.low_latency.as_ref().expect("must have low_latency");
assert_eq!(ll.hold_back, Some(12.0), "HOLD-BACK=12.0 must parse");
let round = pl.to_m3u8();
assert!(
round.contains("HOLD-BACK=12"),
"render must emit HOLD-BACK=12:\n{round}"
);
let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
let re_ll = reparse
.low_latency
.as_ref()
.expect("must have low_latency on reparse");
assert_eq!(
re_ll.hold_back,
Some(12.0),
"HOLD-BACK must survive round trip"
);
}
#[test]
fn hold_back_omitted_when_none() {
let pl = MediaPlaylist {
version: 9,
target_duration: 4,
media_sequence: 0,
segments: vec![MediaSegment {
uri: "seg0.m4s".into(),
duration: 4.0,
..Default::default()
}],
low_latency: Some(LowLatencyConfig {
part_target: 0.5,
part_hold_back: 1.5,
hold_back: None,
can_block_reload: true,
..Default::default()
}),
..Default::default()
};
let out = pl.to_m3u8();
assert!(
!out.contains(",HOLD-BACK="),
"HOLD-BACK must be absent when None:\n{out}"
);
}
#[test]
fn session_key_method_none_is_rejected() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:6\n\
#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
v300/index.m3u8\n\
#EXT-X-SESSION-KEY:METHOD=NONE,URI=\"https://k.example/key\"\n";
let err = MasterPlaylist::parse(text)
.expect_err("EXT-X-SESSION-KEY with METHOD=NONE must be rejected");
let Error::HlsParse { reason, .. } = err;
assert!(reason.contains("NONE"), "error must mention NONE: {reason}");
}
#[test]
fn session_key_real_method_is_accepted_and_round_trips() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:6\n\
#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
v300/index.m3u8\n\
#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"https://k.example/key\"\n";
let pl = MasterPlaylist::parse(text).expect("legitimate session key must parse");
assert_eq!(pl.session_keys.len(), 1);
assert_eq!(pl.session_keys[0].method, EncryptionMethod::Aes128);
let round = pl.to_m3u8();
let reparse = MasterPlaylist::parse(&round).expect("reparse must succeed");
assert_eq!(reparse.session_keys.len(), 1);
assert_eq!(reparse.session_keys[0].method, EncryptionMethod::Aes128);
}
#[test]
fn server_control_typed_and_req_attrs_round_trip_and_version_12() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:12\n\
#EXT-X-TARGETDURATION:4\n\
#EXT-X-PART-INF:PART-TARGET=0.5,REQ-VIDEO=720p\n\
#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\
#EXTINF:4,\n\
seg0.m4s\n";
let pl = MediaPlaylist::parse(text).expect("parse must succeed");
let ll = pl.low_latency.as_ref().expect("must have low_latency");
assert_eq!(ll.hold_back, Some(12.0));
assert!(ll.can_skip_dateranges);
assert!(
ll.pi_extra_attrs
.iter()
.any(|(k, v)| k == "REQ-VIDEO" && v == "720p"),
"REQ-VIDEO must survive in pi_extra_attrs"
);
assert!(
ll.sc_extra_attrs
.iter()
.any(|(k, v)| k == "REQ-LATENCY" && v == "ultra-low"),
"REQ-LATENCY must survive in sc_extra_attrs"
);
assert!(ll.extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
assert!(ll.extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
let round = pl.to_m3u8();
assert!(
round.contains("#EXT-X-VERSION:12"),
"must emit version 12:\n{round}"
);
let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
let re_ll = reparse
.low_latency
.as_ref()
.expect("must have low_latency on reparse");
assert_eq!(re_ll.hold_back, Some(12.0));
assert!(re_ll.can_skip_dateranges);
assert!(re_ll.sc_extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
assert!(re_ll.pi_extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
}
}