use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::cenc::CencScheme;
use crate::error::{Error, Result};
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{}",
crate::rtp::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)]
pub struct MapTag {
pub uri: String,
pub byte_range: Option<ByteRange>,
}
#[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>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SkipInfo {
pub skipped_segments: u64,
pub recently_removed_daterange_ids: Vec<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,
}
#[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, 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>,
}
#[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>,
}
#[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,
}
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,
}
}
}
impl MediaPlaylist {
pub fn to_m3u8(&self) -> String {
let mut s = String::new();
s.push_str("#EXTM3U\n");
let version = if self.iframes_only {
self.version.max(4)
} else {
self.version
};
s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
if self.iframes_only {
s.push_str("#EXT-X-I-FRAMES-ONLY\n");
}
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(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(csu) = ll.can_skip_until {
s.push_str(&format!(",CAN-SKIP-UNTIL={}", format_secs(csu)));
}
s.push('\n');
s.push_str(&format!(
"#EXT-X-PART-INF:PART-TARGET={}\n",
format_secs(ll.part_target),
));
}
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")
));
}
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 {
if let Some(map) = &seg.map {
push_map_line(&mut s, map);
}
}
if self.low_latency.is_some() {
for part in &seg.parts {
push_part_line(&mut s, part);
}
}
s.push_str(&format!("#EXTINF:{:.3},\n", 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() {
if 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 {
if 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 {
if 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}"));
}
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}"));
}
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 = 1;
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 part_target: Option<f64> = None;
let mut part_hold_back: Option<f64> = None;
let mut can_skip_until: 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 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;
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-ENDLIST" {
endlist = true;
} else if line == "#EXT-X-DISCONTINUITY" {
pending_discontinuous = true;
} 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,
};
current_map = Some(MapTag { uri, byte_range });
} 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")?);
}
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_block_reload = attrs.get("CAN-BLOCK-RELOAD").map(String::as_str) == Some("YES");
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,
};
pending_parts.push(PartSpec {
uri,
duration,
independent,
byte_range,
gap,
});
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")?);
}
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,
};
rendition_reports.push(RenditionReport {
uri,
last_msn,
last_part,
});
} 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();
skip = Some(SkipInfo {
skipped_segments,
recently_removed_daterange_ids,
});
} 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(),
});
}
}
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 {
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,
})
} else {
None
};
Ok(MediaPlaylist {
version,
target_duration,
media_sequence,
discontinuity_sequence,
segments,
open_segment,
endlist,
extra_tags,
low_latency,
iframes_only,
rendition_reports,
skip,
})
}
}
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");
}
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()));
}
s.push('\n');
}
fn format_secs(v: f64) -> String {
let millis = (v * 1000.0 + 0.5) as u64;
let whole = millis / 1000;
let frac = millis % 1000;
if frac == 0 {
return format!("{whole}");
}
let mut f = format!("{frac:03}");
while f.ends_with('0') {
f.pop();
}
format!("{whole}.{f}")
}
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 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)]
pub struct Variant {
pub bandwidth: u32,
pub codecs: String,
pub resolution: Option<(u32, u32)>,
pub uri: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IFrameVariant {
pub bandwidth: u32,
pub codecs: Option<String>,
pub resolution: Option<(u32, u32)>,
pub uri: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MasterPlaylist {
pub version: u8,
pub variants: Vec<Variant>,
pub iframe_variants: Vec<IFrameVariant>,
}
type PendingStreamInf = (u32, String, Option<(u32, u32)>);
impl MasterPlaylist {
pub fn to_m3u8(&self) -> String {
let mut s = String::new();
s.push_str("#EXTM3U\n");
s.push_str(&format!("#EXT-X-VERSION:{}\n", self.version));
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}"));
}
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}"));
}
s.push_str(&format!(",URI=\"{}\"\n", iv.uri));
}
s
}
pub fn parse(input: &str) -> Result<Self> {
let mut version: u8 = 1;
let mut variants: Vec<Variant> = Vec::new();
let mut iframe_variants: Vec<IFrameVariant> = Vec::new();
let mut saw_extm3u = false;
let mut pending_stream_inf: Option<PendingStreamInf> = 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,
};
pending_stream_inf = Some((bandwidth, codecs, resolution));
} 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")?;
iframe_variants.push(IFrameVariant {
bandwidth,
codecs,
resolution,
uri,
});
} else if line.starts_with('#') {
} else {
let (bandwidth, codecs, resolution) =
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(),
});
}
}
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,
})
}
}
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(),
},
Variant {
bandwidth: 800_000,
codecs: "avc1.640028,mp4a.40.2".into(),
resolution: Some((1280, 720)),
uri: "v800/index.m3u8".into(),
},
],
iframe_variants: vec![],
};
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(),
}],
iframe_variants: vec![],
};
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.0)]);
let out = pl.to_m3u8();
assert!(out.contains("#EXTINF:9.000,\n"));
}
#[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.000,\n"));
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,
}),
"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,
}),
),
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,
}
}
#[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),
}),
};
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,
},
PartSpec {
uri: "part-9.1.m4s".into(),
duration: 0.5,
independent: false,
byte_range: Some(ByteRange {
length: 500,
offset: Some(1000),
}),
gap: false,
},
],
byte_range: None,
map: Some(map.clone()),
}],
open_segment: Some(
OpenSegment::new(vec![PartSpec {
uri: "part-10.0.m4s".into(),
duration: 0.5,
independent: true,
byte_range: None,
gap: true,
}])
.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),
}],
skip: None,
};
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,
};
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()),
},
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()),
},
],
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,
};
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_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(),
},
Variant {
bandwidth: 800_000,
codecs: "avc1.640028,mp4a.40.2".into(),
resolution: Some((1280, 720)),
uri: "v800/index.m3u8".into(),
},
],
iframe_variants: vec![IFrameVariant {
bandwidth: 50_000,
codecs: Some("avc1.64001e".into()),
resolution: Some((640, 360)),
uri: "v300/iframe.m3u8".into(),
}],
};
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 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");
match err {
Error::HlsParse { reason, .. } => {
assert!(reason.contains("DURATION"), "{reason}");
}
other => panic!("expected HlsParse, got {other:?}"),
}
}
#[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_ignores_ext_x_media() {
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");
}
}