#![doc = include_str!("../README.md")]
#![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::collections::BTreeSet;
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) -> Result<Option<String>> {
let method = match scheme {
CencScheme::Cbcs => "SAMPLE-AES",
CencScheme::Cenc => "SAMPLE-AES-CTR",
_ => return Ok(None),
};
let mut s = format!("#EXT-X-KEY:METHOD={method}");
let attrs = [
(String::from("URI"), AttrValue::quoted(key_uri)?),
(
String::from("KEYFORMAT"),
AttrValue::quoted(CENC_KEYFORMAT)?,
),
(
String::from("KEYFORMATVERSIONS"),
AttrValue::quoted(CENC_KEYFORMATVERSIONS)?,
),
(
String::from("KEYID"),
AttrValue::bare(format!("0x{}", hex_encode(kid)))?,
),
];
render_attribute_list(&mut s, &attrs);
Ok(Some(s))
}
#[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_int::<u64>(n, line_no, line, "BYTERANGE length")?;
let offset = match split.next() {
Some(o) => Some(parse_decimal_int::<u64>(
o,
line_no,
line,
"BYTERANGE offset",
)?),
None => None,
};
if let Some(o) = offset.filter(|&o| o.checked_add(length).is_none()) {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("BYTERANGE offset ({o}) + length ({length}) overflows u64"),
});
}
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, AttrValue)>,
}
#[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, AttrValue)>,
}
#[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, AttrValue)>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PartSpec {
pub uri: String,
pub duration: DecimalSeconds,
pub independent: bool,
pub byte_range: Option<ByteRange>,
pub gap: bool,
pub extra_attrs: Vec<(String, AttrValue)>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct OpenSegment {
pub parts: Vec<PartSpec>,
pub map: Option<MapTag>,
pub pre_tags: Vec<String>,
pub program_date_time: Option<ProgramDateTime>,
}
impl OpenSegment {
pub fn new(parts: Vec<PartSpec>) -> Self {
Self {
parts,
map: None,
pre_tags: Vec::new(),
program_date_time: 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: SignedDecimalSeconds,
pub precise: bool,
pub extra_attrs: Vec<(String, AttrValue)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Define {
Name {
name: String,
value: String,
extra_attrs: Vec<(String, AttrValue)>,
},
Import {
name: String,
extra_attrs: Vec<(String, AttrValue)>,
},
QueryParam {
name: String,
extra_attrs: Vec<(String, AttrValue)>,
},
}
#[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, AttrValue)>,
}
#[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, AttrValue)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ContentSteering {
pub server_uri: String,
pub pathway_id: Option<String>,
pub extra_attrs: Vec<(String, AttrValue)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct ProgramDateTime(jiff::Timestamp);
impl ProgramDateTime {
pub fn parse(s: &str) -> crate::Result<Self> {
let trimmed = s.trim();
let ts = if let Ok(ts) = trimmed.parse::<jiff::Timestamp>() {
ts
} else if let Ok(dt) = trimmed.parse::<jiff::civil::DateTime>() {
dt.to_zoned(jiff::tz::TimeZone::UTC)
.map_err(|_| crate::Error::HlsParse {
line_no: 0,
line: trimmed.to_string(),
reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid date/time".to_string(),
})?
.timestamp()
} else {
return Err(crate::Error::HlsParse {
line_no: 0,
line: trimmed.to_string(),
reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid ISO 8601 date/time"
.to_string(),
});
};
let ms = ts.as_millisecond();
let ts = jiff::Timestamp::from_millisecond(ms).map_err(|_| crate::Error::HlsParse {
line_no: 0,
line: trimmed.to_string(),
reason: "EXT-X-PROGRAM-DATE-TIME value is out of range".to_string(),
})?;
Ok(Self(ts))
}
pub fn from_timestamp(ts: jiff::Timestamp) -> Self {
Self(jiff::Timestamp::from_millisecond(ts.as_millisecond()).unwrap_or(ts))
}
pub fn timestamp(&self) -> jiff::Timestamp {
self.0
}
pub fn render(&self) -> alloc::string::String {
self.0.strftime("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
}
impl core::str::FromStr for ProgramDateTime {
type Err = crate::Error;
fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
Self::parse(s)
}
}
impl core::fmt::Display for ProgramDateTime {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.render())
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MediaSegment {
pub uri: String,
pub duration: DecimalSeconds,
pub discontinuous: bool,
pub parts: Vec<PartSpec>,
pub byte_range: Option<ByteRange>,
pub map: Option<MapTag>,
pub gap: bool,
pub bitrate: Option<u64>,
pub title: Option<String>,
pub pre_tags: Vec<String>,
pub program_date_time: Option<ProgramDateTime>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MediaPlaylist {
pub version: u8,
pub target_duration: u32,
pub media_sequence: u64,
pub discontinuity_sequence: u64,
pub always_emit_discontinuity_sequence: bool,
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: Option<DecimalSeconds>,
pub part_hold_back: Option<DecimalSeconds>,
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<DecimalSeconds>,
pub can_block_reload: bool,
pub sc_extra_attrs: Vec<(String, AttrValue)>,
pub pi_extra_attrs: Vec<(String, AttrValue)>,
pub ph_extra_attrs: Vec<(String, AttrValue)>,
pub hold_back: Option<DecimalSeconds>,
pub can_skip_dateranges: bool,
}
impl LowLatencyConfig {
pub fn effective_part_hold_back(&self) -> Option<DecimalSeconds> {
let Some(part_target) = self.part_target else {
return self.part_hold_back;
};
let floor = 3.0 * part_target.get();
Some(DecimalSeconds::new_unchecked(
self.part_hold_back
.map_or(floor, |phb| phb.get().max(floor)),
))
}
}
impl Default for LowLatencyConfig {
fn default() -> Self {
Self {
part_target: None,
part_hold_back: None,
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,
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: DecimalSeconds) -> 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, _quoted) = parse_attribute_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_attribute_list(rest).0.contains_key("QUERYPARAM") {
bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
}
} else if let Some(rest) = tag.strip_prefix("#EXT-X-MEDIA:") {
let (attrs, _quoted) = parse_attribute_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_attribute_list(&tag[colon + 1..])
.0
.keys()
.any(|k| k.starts_with("REQ-"))
{
bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
}
}
v
}
#[allow(clippy::too_many_arguments)]
fn any_typed_req_attr(
start: Option<&StartPoint>,
defines: &[Define],
sd: &[SessionData],
sk: &[SessionKey],
cs: Option<&ContentSteering>,
media: &[MediaRendition],
variants: &[Variant],
iframes: &[IFrameVariant],
) -> bool {
let extra_attr_is_req = |attrs: &[(String, AttrValue)]| -> 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 media.iter().any(|m| extra_attr_is_req(&m.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, AttrValue)]| -> 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.sc_extra_attrs)
|| extra_attr_is_req(&ll.pi_extra_attrs)
|| extra_attr_is_req(&ll.ph_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.low_latency.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);
}
{
let mut opaque_tags: Vec<&String> = self.extra_tags.iter().collect();
opaque_tags.extend(self.segments.iter().flat_map(|s| &s.pre_tags));
if let Some(open) = &self.open_segment {
opaque_tags.extend(open.pre_tags.iter());
}
let owned: Vec<String> = opaque_tags.into_iter().cloned().collect();
if let Some(m) = scan_tag_lines_for_version(&owned) {
bump_version(&mut v, m);
}
}
if any_media_typed_req_attr(
&self.segments,
self.open_segment
.as_ref()
.filter(|_| self.low_latency.is_some()),
&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())
}
}
pub const KEYFORMAT_IDENTITY: &str = "identity";
pub type MediaKey = SessionKey;
const EXT_X_KEY_PREFIX: &str = "#EXT-X-KEY:";
fn key_format_of(key: &MediaKey) -> &str {
key.keyformat.as_deref().unwrap_or(KEYFORMAT_IDENTITY)
}
#[derive(Default)]
struct KeyState {
identity: Option<MediaKey>,
others: BTreeMap<String, MediaKey>,
}
impl KeyState {
fn apply_tags(&mut self, tags: &[String]) -> Result<()> {
for tag in tags {
let Some(rest) = tag.strip_prefix(EXT_X_KEY_PREFIX) else {
continue;
};
let key = parse_media_key(rest, 0, tag)?;
if key.method != EncryptionMethod::None && key.uri.is_none() {
return Err(Error::KeyWithoutUri { method: key.method });
}
if key.method == EncryptionMethod::None {
self.identity = None;
self.others.clear();
} else if key_format_of(&key) == KEYFORMAT_IDENTITY {
self.identity = Some(key);
} else {
self.others.insert(key_format_of(&key).to_string(), key);
}
}
Ok(())
}
fn effective(&self) -> Result<Option<MediaKey>> {
if let Some(key) = &self.identity {
return Ok(Some(key.clone()));
}
match self.others.keys().next() {
Some(keyformat) => Err(Error::UnsupportedKeyFormat {
keyformat: keyformat.clone(),
}),
None => Ok(None),
}
}
}
impl MediaPlaylist {
pub fn segment_keys(&self) -> Result<Vec<Option<MediaKey>>> {
self.segment_key_results()?.into_iter().collect()
}
pub fn segment_key_results(&self) -> Result<Vec<Result<Option<MediaKey>>>> {
let mut state = KeyState::default();
let mut keys = Vec::with_capacity(self.segments.len());
for seg in &self.segments {
state.apply_tags(&seg.pre_tags)?;
keys.push(state.effective());
}
Ok(keys)
}
pub fn open_segment_key(&self) -> Result<Option<MediaKey>> {
let mut state = KeyState::default();
for seg in &self.segments {
state.apply_tags(&seg.pre_tags)?;
}
if let Some(open) = &self.open_segment {
state.apply_tags(&open.pre_tags)?;
}
state.effective()
}
}
impl MediaPlaylist {
pub fn to_m3u8(&self) -> Result<String> {
self.validate_for_render()?;
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 || self.always_emit_discontinuity_sequence {
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={}",
if ll.can_block_reload { "YES" } else { "NO" },
));
if let Some(phb) = ll.effective_part_hold_back() {
s.push_str(&format!(",PART-HOLD-BACK={}", format_secs(phb)));
}
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");
}
}
render_attribute_list(&mut s, &ll.sc_extra_attrs);
s.push('\n');
if let Some(part_target) = ll.part_target {
s.push_str(&format!(
"#EXT-X-PART-INF:PART-TARGET={}",
format_secs(part_target),
));
render_attribute_list(&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")
));
}
render_attribute_list(&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() {
for tag in &seg.pre_tags {
s.push_str(tag);
s.push('\n');
}
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 let Some(pdt) = &seg.program_date_time {
s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\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");
}
match &seg.title {
Some(title) => {
validate_quoted_string(title, "EXTINF title")?;
s.push_str(&format!(
"#EXTINF:{},{title}\n",
format_extinf(seg.duration)
));
}
None => {
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 let Some(open) = &self.open_segment {
if self.low_latency.is_some() {
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)?;
}
if let Some(pdt) = &open.program_date_time {
s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
}
for part in &open.parts {
push_part_line(&mut s, part)?;
}
} else if let Some(pdt) = &open.program_date_time {
s.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
}
for tag in &open.pre_tags {
s.push_str(tag);
s.push('\n');
}
}
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}"));
}
render_attribute_list(&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}"));
}
render_attribute_list(&mut s, &rr.extra_attrs);
s.push('\n');
}
if self.endlist {
s.push_str("#EXT-X-ENDLIST\n");
}
Ok(s)
}
fn validate_for_render(&self) -> Result<()> {
validate_tag_body("playlist-level extra tag", &self.extra_tags)?;
for seg in &self.segments {
validate_uri(&seg.uri, "segment URI")?;
if let Some(title) = &seg.title {
validate_quoted_string(title, "EXTINF title")?;
}
validate_tag_body("segment pre-tag", &seg.pre_tags)?;
for part in &seg.parts {
validate_uri(&part.uri, "EXT-X-PART URI")?;
}
if let Some(map) = &seg.map {
validate_uri(&map.uri, "EXT-X-MAP URI")?;
}
}
if let Some(open) = &self.open_segment {
validate_tag_body("open-segment pre-tag", &open.pre_tags)?;
for part in &open.parts {
validate_uri(&part.uri, "EXT-X-PART URI")?;
}
if let Some(map) = &open.map {
validate_uri(&map.uri, "EXT-X-MAP URI")?;
}
}
if let Some(ll) = &self.low_latency
&& let Some(uri) = &ll.preload_hint_part
{
validate_uri(uri, "EXT-X-PRELOAD-HINT URI")?;
}
for report in &self.rendition_reports {
validate_uri(&report.uri, "EXT-X-RENDITION-REPORT URI")?;
}
if let Some(skip) = &self.skip {
for id in &skip.recently_removed_daterange_ids {
validate_quoted_string(id, "EXT-X-SKIP RECENTLY-REMOVED-DATERANGES id")?;
if id.contains('\t') {
return Err(Error::InvalidQuotedString {
what: "EXT-X-SKIP RECENTLY-REMOVED-DATERANGES id".to_string(),
bad_char: '\t',
value: id.clone(),
});
}
}
}
Ok(())
}
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 saw_discontinuity_sequence = false;
let mut iframes_only = false;
let mut endlist = false;
let mut pending_title: Option<String> = None;
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<DecimalSeconds> = None;
let mut part_hold_back: Option<DecimalSeconds> = None;
let mut can_skip_until: Option<DecimalSeconds> = None;
let mut can_skip_dateranges = false;
let mut hold_back: Option<DecimalSeconds> = 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, AttrValue)> = Vec::new();
let mut pi_extra_attrs: Vec<(String, AttrValue)> = Vec::new();
let mut ph_extra_attrs: Vec<(String, AttrValue)> = 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<DecimalSeconds> = None;
let mut pending_gap = false;
let mut current_bitrate: Option<u64> = None;
let mut pending_pre_tags: Vec<String> = Vec::new();
let mut pending_program_date_time: Option<ProgramDateTime> = 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_int(rest, line_no, line, "EXT-X-VERSION")?;
} else if let Some(rest) = line.strip_prefix("#EXT-X-TARGETDURATION:") {
target_duration = Some(parse_decimal_int(
rest,
line_no,
line,
"EXT-X-TARGETDURATION",
)?);
} else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
media_sequence = parse_decimal_int(rest, line_no, line, "EXT-X-MEDIA-SEQUENCE")?;
} else if let Some(rest) = line.strip_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:") {
discontinuity_sequence =
parse_decimal_int(rest, line_no, line, "EXT-X-DISCONTINUITY-SEQUENCE")?;
saw_discontinuity_sequence = true;
} 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_int(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, quoted) = parse_attribute_list_strict(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"], "ed)?;
current_map = Some(MapTag {
uri,
byte_range,
extra_attrs,
});
} else if let Some(rest) = line.strip_prefix("#EXTINF:") {
let (dur_str, title) = match rest.split_once(',') {
Some((dur, title)) => (dur, title),
None => (rest, ""),
};
pending_duration = Some(parse_decimal_seconds(
dur_str,
line_no,
line,
"EXTINF duration",
)?);
pending_title = (!title.is_empty()).then(|| title.to_string());
} else if let Some(rest) = line.strip_prefix("#EXT-X-PART-INF:") {
let (attrs, quoted) = parse_attribute_list_strict(rest)?;
if let Some(v) = attrs.get("PART-TARGET") {
part_target = Some(parse_decimal_seconds(v, line_no, line, "PART-TARGET")?);
}
pi_extra_attrs.extend(filter_extra_attrs(&attrs, &["PART-TARGET"], "ed)?);
saw_ll_tag = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-SERVER-CONTROL:") {
let (attrs, quoted) = parse_attribute_list_strict(rest)?;
if let Some(v) = attrs.get("PART-HOLD-BACK") {
part_hold_back =
Some(parse_decimal_seconds(v, line_no, line, "PART-HOLD-BACK")?);
}
if let Some(v) = attrs.get("CAN-SKIP-UNTIL") {
can_skip_until =
Some(parse_decimal_seconds(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_seconds(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",
],
"ed,
)?);
saw_ll_tag = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-PART:") {
let (attrs, quoted) = parse_attribute_list_strict(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_seconds(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"],
"ed,
)?;
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, quoted) = parse_attribute_list_strict(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_int(v, line_no, line, "BYTERANGE-START")?);
}
if let Some(v) = attrs.get("BYTERANGE-LENGTH") {
preload_hint_byte_range_length =
Some(parse_decimal_int(v, line_no, line, "BYTERANGE-LENGTH")?);
}
ph_extra_attrs.extend(filter_extra_attrs(
&attrs,
&["TYPE", "URI", "BYTERANGE-START", "BYTERANGE-LENGTH"],
"ed,
)?);
saw_ll_tag = true;
} else if let Some(rest) = line.strip_prefix("#EXT-X-RENDITION-REPORT:") {
let (attrs, quoted) = parse_attribute_list_strict(rest)?;
let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-RENDITION-REPORT")?;
let last_msn_str =
require_attr(&attrs, "LAST-MSN", line_no, line, "EXT-X-RENDITION-REPORT")?;
let last_msn = parse_decimal_int(&last_msn_str, line_no, line, "LAST-MSN")?;
let last_part = match attrs.get("LAST-PART") {
Some(v) => Some(parse_decimal_int(v, line_no, line, "LAST-PART")?),
None => None,
};
let extra_attrs =
filter_extra_attrs(&attrs, &["URI", "LAST-MSN", "LAST-PART"], "ed)?;
rendition_reports.push(RenditionReport {
uri,
last_msn,
last_part,
extra_attrs,
});
} else if let Some(rest) = line.strip_prefix("#EXT-X-SKIP:") {
let (attrs, quoted) = parse_attribute_list_strict(rest)?;
let skipped_segments_str =
require_attr(&attrs, "SKIPPED-SEGMENTS", line_no, line, "EXT-X-SKIP")?;
let skipped_segments =
parse_decimal_int(&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"],
"ed,
)?;
skip = Some(SkipInfo {
skipped_segments,
recently_removed_daterange_ids,
extra_attrs,
});
} else if let Some(rest) = line.strip_prefix("#EXT-X-PROGRAM-DATE-TIME:") {
let pdt =
ProgramDateTime::parse(rest.trim()).map_err(|_| crate::Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-PROGRAM-DATE-TIME value is not a valid ISO 8601 date/time"
.to_string(),
})?;
pending_program_date_time = Some(pdt);
} else if let Some(rest) = line.strip_prefix("#EXT") {
let _ = rest;
if is_segment_defining_tag(line) {
pending_pre_tags.push(line.to_string());
} else {
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,
title: pending_title.take(),
pre_tags: core::mem::take(&mut pending_pre_tags),
program_date_time: pending_program_date_time.take(),
});
}
}
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()
&& pending_pre_tags.is_empty()
&& pending_program_date_time.is_none()
{
None
} else {
let open = OpenSegment::new(core::mem::take(&mut pending_parts));
let open = match ¤t_map {
Some(map) => open.with_map(map.clone()),
None => open,
};
Some(OpenSegment {
pre_tags: core::mem::take(&mut pending_pre_tags),
program_date_time: pending_program_date_time.take(),
..open
})
};
if let (Some(start), Some(len)) = (
preload_hint_byte_range_start,
preload_hint_byte_range_length,
) && start.checked_add(len).is_none()
{
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: format!(
"PRELOAD-HINT BYTERANGE-START ({start}) + BYTERANGE-LENGTH ({len}) overflows u64"
),
});
}
let low_latency = if saw_ll_tag {
Some(LowLatencyConfig {
part_target,
part_hold_back,
preload_hint_part,
preload_hint_type,
preload_hint_byte_range_start,
preload_hint_byte_range_length,
can_skip_until,
can_block_reload,
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,
always_emit_discontinuity_sequence: saw_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) -> Result<()> {
validate_uri(&part.uri, "EXT-X-PART URI")?;
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");
}
render_attribute_list(s, &part.extra_attrs);
s.push('\n');
Ok(())
}
fn push_map_line(s: &mut String, map: &MapTag) -> Result<()> {
validate_uri(&map.uri, "EXT-X-MAP URI")?;
s.push_str(&format!("#EXT-X-MAP:URI=\"{}\"", map.uri));
if let Some(br) = &map.byte_range {
s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
}
render_attribute_list(s, &map.extra_attrs);
s.push('\n');
Ok(())
}
fn push_define_line(s: &mut String, def: &Define) -> Result<()> {
match def {
Define::Name {
name,
value,
extra_attrs,
} => {
validate_quoted_string(name, "EXT-X-DEFINE NAME")?;
validate_quoted_string(value, "EXT-X-DEFINE VALUE")?;
s.push_str(&format!("#EXT-X-DEFINE:NAME=\"{name}\",VALUE=\"{value}\""));
render_attribute_list(s, extra_attrs);
s.push('\n');
}
Define::Import { name, extra_attrs } => {
validate_quoted_string(name, "EXT-X-DEFINE IMPORT")?;
s.push_str(&format!("#EXT-X-DEFINE:IMPORT=\"{name}\""));
render_attribute_list(s, extra_attrs);
s.push('\n');
}
Define::QueryParam { name, extra_attrs } => {
validate_quoted_string(name, "EXT-X-DEFINE QUERYPARAM")?;
s.push_str(&format!("#EXT-X-DEFINE:QUERYPARAM=\"{name}\""));
render_attribute_list(s, extra_attrs);
s.push('\n');
}
}
Ok(())
}
fn parse_define(rest: &str, line_no: usize, line: &str) -> Result<Define> {
let (attrs, quoted) = parse_attribute_list_strict(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"], "ed)?;
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"], "ed)?;
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"], "ed)?;
Ok(Define::QueryParam { name, extra_attrs })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttrValue {
value: String,
quoted: bool,
}
impl AttrValue {
pub fn quoted(value: impl Into<String>) -> Result<Self> {
let value = value.into();
if value.contains('"') || value.contains('\r') || value.contains('\n') {
return Err(Error::InvalidAttrValue {
value,
kind: "a quoted-string",
});
}
Ok(Self {
value,
quoted: true,
})
}
pub fn bare(value: impl Into<String>) -> Result<Self> {
let value = value.into();
if value.contains(',')
|| value.contains('"')
|| value.contains('\r')
|| value.contains('\n')
|| value.contains(char::is_whitespace)
{
return Err(Error::InvalidAttrValue {
value,
kind: "a bare value",
});
}
Ok(Self {
value,
quoted: false,
})
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.value
}
#[must_use]
pub fn is_quoted(&self) -> bool {
self.quoted
}
pub fn for_attr(name: &str, value: impl Into<String>) -> Result<Self> {
let value = value.into();
match known_attr_value_kind(name) {
Some(AttrValueKind::Quoted) => Self::quoted(value),
Some(AttrValueKind::QuotedUnless(bare)) => {
if value == bare {
Self::bare(value)
} else {
Self::quoted(value)
}
}
None => {
if value.is_empty()
|| value.contains(',')
|| value.contains('"')
|| value.contains(char::is_whitespace)
{
Self::quoted(value)
} else {
Self::bare(value)
}
}
}
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for AttrValue {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> core::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut s = serializer.serialize_struct("AttrValue", 2)?;
s.serialize_field("value", &self.value)?;
s.serialize_field("quoted", &self.quoted)?;
s.end()
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for AttrValue {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> core::result::Result<Self, D::Error> {
#[derive(serde::Deserialize)]
struct Raw {
value: String,
quoted: bool,
}
let raw = Raw::deserialize(deserializer)?;
if raw.quoted {
AttrValue::quoted(raw.value)
} else {
AttrValue::bare(raw.value)
}
.map_err(serde::de::Error::custom)
}
}
enum AttrValueKind {
Quoted,
QuotedUnless(&'static str),
}
fn known_attr_value_kind(name: &str) -> Option<AttrValueKind> {
match name {
"AUDIO"
| "VIDEO"
| "SUBTITLES"
| "PATHWAY-ID"
| "STABLE-VARIANT-ID"
| "STABLE-RENDITION-ID"
| "SUPPLEMENTAL-CODECS"
| "REQ-VIDEO-LAYOUT"
| "ALLOWED-CPC" => Some(AttrValueKind::Quoted),
"CLOSED-CAPTIONS" => Some(AttrValueKind::QuotedUnless("NONE")),
_ => None,
}
}
pub fn render_attribute_list(s: &mut String, attrs: &[(String, AttrValue)]) {
for (name, value) in attrs {
s.push(',');
s.push_str(name);
s.push('=');
if value.is_quoted() {
s.push('"');
s.push_str(value.as_str());
s.push('"');
} else {
s.push_str(value.as_str());
}
}
}
const QUOTED_STRING_FORBIDDEN: &[char] = &['"', '\r', '\n'];
fn validate_quoted_string(value: &str, what: &str) -> Result<()> {
if let Some(bad) = value.chars().find(|c| QUOTED_STRING_FORBIDDEN.contains(c)) {
return Err(Error::InvalidQuotedString {
what: what.to_string(),
bad_char: bad,
value: value.to_string(),
});
}
Ok(())
}
fn validate_tag_body(what: &str, tags: &[String]) -> Result<()> {
for tag in tags {
if let Some(bad) = tag.chars().find(|c| *c == '\r' || *c == '\n') {
return Err(Error::InvalidQuotedString {
what: what.to_string(),
bad_char: bad,
value: tag.clone(),
});
}
}
Ok(())
}
fn validate_uri(value: &str, what: &str) -> Result<()> {
if let Some(bad) = value.chars().find(|c| QUOTED_STRING_FORBIDDEN.contains(c)) {
return Err(Error::InvalidUri {
what: what.to_string(),
bad_char: bad,
value: value.to_string(),
});
}
Ok(())
}
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");
}
render_attribute_list(s, &start.extra_attrs);
s.push('\n');
}
fn parse_start(rest: &str, line_no: usize, line: &str) -> Result<StartPoint> {
let (attrs, quoted) = parse_attribute_list_strict(rest)?;
let time_offset_str = require_attr(&attrs, "TIME-OFFSET", line_no, line, "EXT-X-START")?;
let time_offset = parse_signed_decimal_seconds(&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"], "ed)?;
Ok(StartPoint {
time_offset,
precise,
extra_attrs,
})
}
fn push_session_data_line(s: &mut String, sd: &SessionData) -> Result<()> {
validate_quoted_string(&sd.data_id, "EXT-X-SESSION-DATA DATA-ID")?;
s.push_str(&format!("#EXT-X-SESSION-DATA:DATA-ID=\"{}\"", sd.data_id));
match &sd.content {
SessionDataContent::Value(v) => {
validate_quoted_string(v, "EXT-X-SESSION-DATA VALUE")?;
s.push_str(&format!(",VALUE=\"{v}\""));
}
SessionDataContent::Uri { uri, format } => {
validate_uri(uri, "EXT-X-SESSION-DATA URI")?;
s.push_str(&format!(",URI=\"{uri}\""));
if *format == SessionDataFormat::Raw {
s.push_str(",FORMAT=RAW");
}
}
}
if let Some(lang) = &sd.language {
validate_quoted_string(lang, "EXT-X-SESSION-DATA LANGUAGE")?;
s.push_str(&format!(",LANGUAGE=\"{lang}\""));
}
render_attribute_list(s, &sd.extra_attrs);
s.push('\n');
Ok(())
}
fn parse_session_data(rest: &str, line_no: usize, line: &str) -> Result<SessionData> {
let (attrs, quoted) = parse_attribute_list_strict(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, "ed)?;
Ok(SessionData {
data_id,
content,
language,
extra_attrs,
})
}
fn push_session_key_line(s: &mut String, sk: &SessionKey) -> Result<()> {
s.push_str(&format!("#EXT-X-SESSION-KEY:METHOD={}", sk.method.name()));
if let Some(uri) = &sk.uri {
validate_uri(uri, "EXT-X-SESSION-KEY 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 {
validate_quoted_string(kf, "EXT-X-SESSION-KEY KEYFORMAT")?;
s.push_str(&format!(",KEYFORMAT=\"{kf}\""));
}
if let Some(kfv) = &sk.keyformatversions {
validate_quoted_string(kfv, "EXT-X-SESSION-KEY KEYFORMATVERSIONS")?;
s.push_str(&format!(",KEYFORMATVERSIONS=\"{kfv}\""));
}
render_attribute_list(s, &sk.extra_attrs);
s.push('\n');
Ok(())
}
fn parse_session_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
parse_key_attributes(rest, line_no, line, "EXT-X-SESSION-KEY", false)
}
fn parse_media_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
parse_key_attributes(rest, line_no, line, "EXT-X-KEY", true)
}
fn parse_key_attributes(
rest: &str,
line_no: usize,
line: &str,
tag: &str,
allow_none: bool,
) -> Result<SessionKey> {
let (attrs, quoted) = parse_attribute_list_strict(rest)?;
let method_str = require_attr(&attrs, "METHOD", line_no, line, tag)?;
let method = match method_str.as_str() {
"NONE" if allow_none => EncryptionMethod::None,
"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!("{tag} METHOD value {other:?} is not recognized"),
});
}
};
if method == EncryptionMethod::None && attrs.len() > 1 {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!(
"{tag} METHOD=NONE MUST NOT carry other attributes (RFC 8216bis §4.4.4.4)"
),
});
}
let uri = attrs.get("URI").cloned();
let iv = match attrs.get("IV") {
Some(v) => Some(parse_iv(v)?),
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"],
"ed,
)?;
Ok(SessionKey {
method,
uri,
iv,
keyformat,
keyformatversions,
extra_attrs,
})
}
const IV_HEX_DIGITS: usize = 32;
fn parse_iv(s: &str) -> Result<[u8; 16]> {
let hex = s
.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.filter(|h| h.len() == IV_HEX_DIGITS && h.bytes().all(|b| b.is_ascii_hexdigit()))
.ok_or_else(|| Error::InvalidIv {
value: s.to_string(),
})?;
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: SignedDecimalSeconds) -> String {
let v = v.get();
if v < 0.0 {
format!("-{}", format_secs(DecimalSeconds::new_unchecked(-v)))
} else {
format_secs(DecimalSeconds::new_unchecked(v))
}
}
fn format_secs(v: DecimalSeconds) -> String {
let v = v.get();
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: DecimalSeconds) -> String {
let v = v.get();
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_float(
s: &str,
line_no: usize,
line: &str,
what: &str,
signed: bool,
) -> Result<f64> {
let t = s.trim();
if !is_valid_decimal_token(t, signed) {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("{what} value {s:?} is not a valid decimal number"),
});
}
t.parse::<f64>().map_err(|_| Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("{what} value {s:?} is not a valid number"),
})
}
fn parse_decimal_int<T: core::str::FromStr>(
s: &str,
line_no: usize,
line: &str,
what: &str,
) -> Result<T> {
let t = s.trim();
if !is_valid_decimal_token(t, false) {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("{what} value {s:?} is not a valid decimal integer"),
});
}
t.parse::<T>().map_err(|_| Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("{what} value {s:?} is not a valid number"),
})
}
fn is_valid_decimal_token(s: &str, signed: bool) -> bool {
let s = s.strip_prefix('-').filter(|_| signed).unwrap_or(s);
let (int_part, frac_part) = match s.split_once('.') {
Some((i, f)) => (i, Some(f)),
None => (s, None),
};
if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
match frac_part {
Some(f) => !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()),
None => true,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DecimalSeconds(f64);
impl DecimalSeconds {
pub const ZERO: Self = Self(0.0);
pub fn new(value: f64) -> Result<Self> {
if value.is_finite() && value >= 0.0 {
Ok(Self(value))
} else {
Err(Error::InvalidDecimalSeconds(value))
}
}
#[must_use]
pub fn get(self) -> f64 {
self.0
}
fn new_unchecked(value: f64) -> Self {
debug_assert!(value.is_finite() && value >= 0.0, "{value} must be valid");
Self(value)
}
}
impl Default for DecimalSeconds {
fn default() -> Self {
Self::ZERO
}
}
impl core::fmt::Display for DecimalSeconds {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
core::fmt::Display::fmt(&self.0, f)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SignedDecimalSeconds(f64);
impl SignedDecimalSeconds {
pub const ZERO: Self = Self(0.0);
pub fn new(value: f64) -> Result<Self> {
if value.is_finite() {
Ok(Self(value))
} else {
Err(Error::InvalidSignedDecimalSeconds(value))
}
}
#[must_use]
pub fn get(self) -> f64 {
self.0
}
fn new_unchecked(value: f64) -> Self {
debug_assert!(value.is_finite(), "{value} must be finite");
Self(value)
}
}
impl core::fmt::Display for SignedDecimalSeconds {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
core::fmt::Display::fmt(&self.0, f)
}
}
fn parse_decimal_seconds(
s: &str,
line_no: usize,
line: &str,
what: &str,
) -> Result<DecimalSeconds> {
parse_decimal_float(s, line_no, line, what, false).map(DecimalSeconds::new_unchecked)
}
fn parse_signed_decimal_seconds(
s: &str,
line_no: usize,
line: &str,
what: &str,
) -> Result<SignedDecimalSeconds> {
parse_decimal_float(s, line_no, line, what, true).map(SignedDecimalSeconds::new_unchecked)
}
pub fn parse_attribute_list(s: &str) -> (BTreeMap<String, String>, BTreeSet<String>) {
scan_attribute_list(s, false).unwrap_or_default()
}
pub fn parse_attribute_list_strict(
s: &str,
) -> Result<(BTreeMap<String, String>, BTreeSet<String>)> {
scan_attribute_list(s, true)
}
fn scan_attribute_list(
s: &str,
strict: bool,
) -> Result<(BTreeMap<String, String>, BTreeSet<String>)> {
let mut map = BTreeMap::new();
let mut quoted_keys = BTreeSet::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;
let quoted = i < len && bytes[i] == b'"';
let value = if quoted {
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;
} else if strict {
return Err(Error::UnterminatedQuotedString {
list: s.to_string(),
});
}
value
} else {
let value_start = i;
while i < len && bytes[i] != b',' {
i += 1;
}
&s[value_start..i]
};
if strict && map.contains_key(key) {
return Err(Error::DuplicateAttribute {
name: key.to_string(),
});
}
map.insert(key.to_string(), value.to_string());
if quoted {
quoted_keys.insert(key.to_string());
}
}
Ok((map, quoted_keys))
}
fn filter_extra_attrs(
attrs: &BTreeMap<String, String>,
known: &[&str],
quoted: &BTreeSet<String>,
) -> Result<Vec<(String, AttrValue)>> {
attrs
.iter()
.filter(|(k, _)| !known.contains(&k.as_str()))
.map(|(k, v)| {
let value = if quoted.contains(k) {
AttrValue::quoted(v.clone())?
} else {
AttrValue::bare(v.clone())?
};
Ok((k.clone(), value))
})
.collect()
}
fn is_segment_defining_tag(line: &str) -> bool {
const SEGMENT_SCOPED: &[&str] = &["#EXT-X-KEY:", "#EXT-X-DATERANGE:", "#EXT-X-CUE-"];
SEGMENT_SCOPED.iter().any(|prefix| line.starts_with(prefix))
}
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, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum VideoRange {
Sdr,
Hlg,
Pq,
}
impl VideoRange {
pub fn name(&self) -> &'static str {
match self {
VideoRange::Sdr => "SDR",
VideoRange::Hlg => "HLG",
VideoRange::Pq => "PQ",
}
}
pub fn from_transfer_characteristics(code: u8) -> Option<Self> {
const TRANSFER_BT709: u8 = 1;
const TRANSFER_BT601: u8 = 6;
const TRANSFER_IEC_61966_2_1: u8 = 13;
const TRANSFER_BT2020_10BIT: u8 = 14;
const TRANSFER_BT2020_12BIT: u8 = 15;
const TRANSFER_SMPTE_ST_2084: u8 = 16;
const TRANSFER_ARIB_STD_B67: u8 = 18;
match code {
TRANSFER_BT709
| TRANSFER_BT601
| TRANSFER_IEC_61966_2_1
| TRANSFER_BT2020_10BIT
| TRANSFER_BT2020_12BIT => Some(Self::Sdr),
TRANSFER_SMPTE_ST_2084 => Some(Self::Pq),
TRANSFER_ARIB_STD_B67 => Some(Self::Hlg),
_ => None,
}
}
pub fn attr(self) -> (String, AttrValue) {
(
String::from("VIDEO-RANGE"),
AttrValue::bare(self.name()).expect("VIDEO-RANGE token is a valid bare value"),
)
}
}
broadcast_common::impl_spec_display!(VideoRange);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FrameRate(u32);
impl FrameRate {
#[must_use]
pub fn from_millihertz(millihertz: u32) -> Self {
Self(millihertz)
}
#[must_use]
pub fn millihertz(self) -> u32 {
self.0
}
}
impl core::fmt::Display for FrameRate {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let whole = self.0 / 1000;
let frac = self.0 % 1000;
write!(f, "{whole}.{frac:03}")
}
}
impl core::str::FromStr for FrameRate {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self> {
parse_frame_rate(s, 0, s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum MediaType {
#[default]
Audio,
Video,
Subtitles,
ClosedCaptions,
}
impl MediaType {
#[must_use]
pub fn name(&self) -> &'static str {
match self {
MediaType::Audio => "AUDIO",
MediaType::Video => "VIDEO",
MediaType::Subtitles => "SUBTITLES",
MediaType::ClosedCaptions => "CLOSED-CAPTIONS",
}
}
}
broadcast_common::impl_spec_display!(MediaType);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum InstreamId {
Cc(u8),
Service(u8),
Other(String),
}
const INSTREAM_CC_PREFIX: &str = "CC";
const INSTREAM_SERVICE_PREFIX: &str = "SERVICE";
const INSTREAM_CC_RANGE: core::ops::RangeInclusive<u8> = 1..=4;
const INSTREAM_SERVICE_RANGE: core::ops::RangeInclusive<u8> = 1..=63;
impl InstreamId {
#[must_use]
pub fn render(&self) -> String {
match self {
InstreamId::Cc(n) => format!("{INSTREAM_CC_PREFIX}{n}"),
InstreamId::Service(n) => format!("{INSTREAM_SERVICE_PREFIX}{n}"),
InstreamId::Other(s) => s.clone(),
}
}
pub fn parse_closed_captions(value: &str) -> Result<Self> {
let invalid = || Error::InvalidInstreamId {
value: value.to_string(),
};
let canonical_decimal = |digits: &str| -> Option<u8> {
let well_formed = !digits.is_empty()
&& digits.bytes().all(|b| b.is_ascii_digit())
&& !digits.starts_with('0');
well_formed.then(|| digits.parse().ok()).flatten()
};
if let Some(rest) = value.strip_prefix(INSTREAM_CC_PREFIX) {
return canonical_decimal(rest)
.filter(|n| INSTREAM_CC_RANGE.contains(n))
.map(InstreamId::Cc)
.ok_or_else(invalid);
}
if let Some(rest) = value.strip_prefix(INSTREAM_SERVICE_PREFIX) {
return canonical_decimal(rest)
.filter(|n| INSTREAM_SERVICE_RANGE.contains(n))
.map(InstreamId::Service)
.ok_or_else(invalid);
}
Err(invalid())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClosedCaptionsRef {
Group(String),
None,
}
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct MediaRendition {
pub media_type: MediaType,
pub uri: Option<String>,
pub group_id: String,
pub language: Option<String>,
pub assoc_language: Option<String>,
pub name: String,
pub stable_rendition_id: Option<String>,
pub default: bool,
pub autoselect: bool,
pub forced: bool,
pub instream_id: Option<InstreamId>,
pub bit_depth: Option<u32>,
pub sample_rate: Option<u32>,
pub characteristics: Vec<String>,
pub channels: Option<String>,
pub extra_attrs: Vec<(String, AttrValue)>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Variant {
pub bandwidth: u32,
pub average_bandwidth: Option<u32>,
pub frame_rate: Option<FrameRate>,
pub codecs: Option<String>,
pub resolution: Option<(u32, u32)>,
pub uri: String,
pub audio: Option<String>,
pub video: Option<String>,
pub subtitles: Option<String>,
pub closed_captions: Option<ClosedCaptionsRef>,
pub extra_attrs: Vec<(String, AttrValue)>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct IFrameVariant {
pub bandwidth: u32,
pub average_bandwidth: Option<u32>,
pub codecs: Option<String>,
pub resolution: Option<(u32, u32)>,
pub uri: String,
pub extra_attrs: Vec<(String, AttrValue)>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MasterPlaylist {
pub version: u8,
pub media: Vec<MediaRendition>,
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,
Option<u32>,
Option<FrameRate>,
Option<String>,
Option<(u32, u32)>,
Vec<(String, AttrValue)>,
);
impl MasterPlaylist {
pub fn to_m3u8(&self) -> Result<String> {
self.validate_for_render()?;
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');
}
for m in &self.media {
s.push_str("#EXT-X-MEDIA:TYPE=");
s.push_str(m.media_type.name());
if let Some(uri) = &m.uri {
validate_uri(uri, "EXT-X-MEDIA URI")?;
s.push_str(&format!(",URI=\"{uri}\""));
}
s.push_str(&format!(",GROUP-ID=\"{}\"", m.group_id));
if let Some(l) = &m.language {
validate_quoted_string(l, "EXT-X-MEDIA LANGUAGE")?;
s.push_str(&format!(",LANGUAGE=\"{l}\""));
}
if let Some(l) = &m.assoc_language {
validate_quoted_string(l, "EXT-X-MEDIA ASSOC-LANGUAGE")?;
s.push_str(&format!(",ASSOC-LANGUAGE=\"{l}\""));
}
validate_quoted_string(&m.name, "EXT-X-MEDIA NAME")?;
s.push_str(&format!(",NAME=\"{}\"", m.name));
if let Some(sri) = &m.stable_rendition_id {
validate_quoted_string(sri, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
s.push_str(&format!(",STABLE-RENDITION-ID=\"{sri}\""));
}
s.push_str(&format!(
",DEFAULT={}",
if m.default { "YES" } else { "NO" }
));
if m.autoselect || !m.default {
s.push_str(&format!(
",AUTOSELECT={}",
if m.autoselect { "YES" } else { "NO" }
));
}
if m.forced {
s.push_str(",FORCED=YES");
} else if m.media_type == MediaType::Subtitles {
s.push_str(",FORCED=NO");
}
if let Some(ii) = &m.instream_id {
let v = ii.render();
validate_quoted_string(&v, "EXT-X-MEDIA INSTREAM-ID")?;
s.push_str(&format!(",INSTREAM-ID=\"{v}\""));
}
if let Some(bd) = m.bit_depth {
s.push_str(&format!(",BIT-DEPTH={bd}"));
}
if let Some(sr) = m.sample_rate {
s.push_str(&format!(",SAMPLE-RATE={sr}"));
}
if !m.characteristics.is_empty() {
let joined = m.characteristics.join(",");
validate_quoted_string(&joined, "EXT-X-MEDIA CHARACTERISTICS")?;
s.push_str(&format!(",CHARACTERISTICS=\"{joined}\""));
}
if let Some(ch) = &m.channels {
validate_quoted_string(ch, "EXT-X-MEDIA CHANNELS")?;
s.push_str(&format!(",CHANNELS=\"{ch}\""));
}
render_attribute_list(&mut s, &m.extra_attrs);
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 {
validate_uri(&cs.server_uri, "EXT-X-CONTENT-STEERING SERVER-URI")?;
s.push_str(&format!(
"#EXT-X-CONTENT-STEERING:SERVER-URI=\"{}\"",
cs.server_uri
));
if let Some(pid) = &cs.pathway_id {
validate_quoted_string(pid, "EXT-X-CONTENT-STEERING PATHWAY-ID")?;
s.push_str(&format!(",PATHWAY-ID=\"{pid}\""));
}
render_attribute_list(&mut s, &cs.extra_attrs);
s.push('\n');
}
for var in &self.variants {
s.push_str(&format!("#EXT-X-STREAM-INF:BANDWIDTH={}", var.bandwidth));
if let Some(avg) = var.average_bandwidth {
s.push_str(&format!(",AVERAGE-BANDWIDTH={avg}"));
}
if let Some(codecs) = &var.codecs {
validate_quoted_string(codecs, "EXT-X-STREAM-INF CODECS")?;
s.push_str(&format!(",CODECS=\"{codecs}\""));
}
if let Some((w, h)) = var.resolution {
s.push_str(&format!(",RESOLUTION={w}x{h}"));
}
if let Some(frame_rate) = var.frame_rate {
s.push_str(&format!(",FRAME-RATE={frame_rate}"));
}
if let Some(g) = &var.audio {
validate_quoted_string(g, "EXT-X-STREAM-INF AUDIO")?;
s.push_str(&format!(",AUDIO=\"{g}\""));
}
if let Some(g) = &var.video {
validate_quoted_string(g, "EXT-X-STREAM-INF VIDEO")?;
s.push_str(&format!(",VIDEO=\"{g}\""));
}
if let Some(g) = &var.subtitles {
validate_quoted_string(g, "EXT-X-STREAM-INF SUBTITLES")?;
s.push_str(&format!(",SUBTITLES=\"{g}\""));
}
match &var.closed_captions {
Some(ClosedCaptionsRef::Group(g)) => {
validate_quoted_string(g, "EXT-X-STREAM-INF CLOSED-CAPTIONS")?;
s.push_str(&format!(",CLOSED-CAPTIONS=\"{g}\""));
}
Some(ClosedCaptionsRef::None) => {
s.push_str(",CLOSED-CAPTIONS=NONE");
}
None => {}
}
render_attribute_list(&mut s, &var.extra_attrs);
s.push('\n');
validate_uri(&var.uri, "variant URI")?;
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(avg) = iv.average_bandwidth {
s.push_str(&format!(",AVERAGE-BANDWIDTH={avg}"));
}
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}"));
}
validate_uri(&iv.uri, "I-frame-variant URI")?;
if let Some(codecs) = &iv.codecs {
validate_quoted_string(codecs, "EXT-X-I-FRAME-STREAM-INF CODECS")?;
}
render_attribute_list(&mut s, &iv.extra_attrs);
s.push_str(&format!(",URI=\"{}\"\n", iv.uri));
}
Ok(s)
}
fn validate_for_render(&self) -> Result<()> {
validate_tag_body("playlist-level extra tag", &self.extra_tags)?;
for def in &self.defines {
push_define_line(&mut String::new(), def)?;
}
if let Some(cs) = &self.content_steering {
validate_uri(&cs.server_uri, "EXT-X-CONTENT-STEERING SERVER-URI")?;
if let Some(pid) = &cs.pathway_id {
validate_quoted_string(pid, "EXT-X-CONTENT-STEERING PATHWAY-ID")?;
}
}
for var in &self.variants {
validate_uri(&var.uri, "variant URI")?;
if let Some(codecs) = &var.codecs {
validate_quoted_string(codecs, "EXT-X-STREAM-INF CODECS")?;
}
}
for iv in &self.iframe_variants {
validate_uri(&iv.uri, "I-frame-variant URI")?;
if let Some(codecs) = &iv.codecs {
validate_quoted_string(codecs, "EXT-X-I-FRAME-STREAM-INF CODECS")?;
}
}
check_rendition_groups(&self.media)?;
for m in &self.media {
if m.group_id.is_empty() {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "EXT-X-MEDIA GROUP-ID must be non-empty".to_string(),
});
}
validate_quoted_string(&m.group_id, "EXT-X-MEDIA GROUP-ID")?;
if m.name.is_empty() {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "EXT-X-MEDIA NAME must be non-empty".to_string(),
});
}
validate_quoted_string(&m.name, "EXT-X-MEDIA NAME")?;
if m.media_type == MediaType::ClosedCaptions {
if m.uri.is_some() {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "EXT-X-MEDIA URI MUST NOT be present when TYPE is CLOSED-CAPTIONS"
.to_string(),
});
}
if m.instream_id.is_none() {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "EXT-X-MEDIA INSTREAM-ID REQUIRED when TYPE is CLOSED-CAPTIONS"
.to_string(),
});
}
} else {
}
if m.forced && m.media_type != MediaType::Subtitles {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES"
.to_string(),
});
}
if m.bit_depth.is_some() && m.media_type != MediaType::Audio {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "EXT-X-MEDIA BIT-DEPTH MUST NOT be present unless TYPE is AUDIO"
.to_string(),
});
}
if m.sample_rate.is_some() && m.media_type != MediaType::Audio {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "EXT-X-MEDIA SAMPLE-RATE MUST NOT be present unless TYPE is AUDIO"
.to_string(),
});
}
if m.channels.is_some() && m.media_type != MediaType::Audio {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "EXT-X-MEDIA CHANNELS MUST NOT be present unless TYPE is AUDIO"
.to_string(),
});
}
if let Some(uri) = &m.uri {
validate_uri(uri, "EXT-X-MEDIA URI")?;
}
if let Some(sri) = &m.stable_rendition_id {
validate_quoted_string(sri, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
}
if let Some(ii) = &m.instream_id {
let v = ii.render();
validate_quoted_string(&v, "EXT-X-MEDIA INSTREAM-ID")?;
let closed_captions = m.media_type == MediaType::ClosedCaptions;
let cc_form = !matches!(ii, InstreamId::Other(_));
if closed_captions != cc_form {
return Err(Error::InstreamIdTypeMismatch {
media_type: m.media_type,
value: v,
});
}
if closed_captions {
InstreamId::parse_closed_captions(&v)?;
}
}
if let Some(ch) = &m.channels {
validate_quoted_string(ch, "EXT-X-MEDIA CHANNELS")?;
}
}
let none_used = self
.variants
.iter()
.any(|v| matches!(v.closed_captions, Some(ClosedCaptionsRef::None)));
if none_used {
for v in &self.variants {
if !matches!(v.closed_captions, Some(ClosedCaptionsRef::None)) {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason:
"EXT-X-STREAM-INF CLOSED-CAPTIONS=NONE must be used on all variants"
.to_string(),
});
}
}
}
let group_exists = |gid: &str, mt: MediaType| -> bool {
self.media
.iter()
.any(|m| m.group_id == gid && m.media_type == mt)
};
for v in &self.variants {
if let Some(g) = &v.audio
&& !group_exists(g, MediaType::Audio)
{
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: format!("EXT-X-STREAM-INF AUDIO group {g:?} not found"),
});
}
if let Some(g) = &v.video
&& !group_exists(g, MediaType::Video)
{
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: format!("EXT-X-STREAM-INF VIDEO group {g:?} not found"),
});
}
if let Some(g) = &v.subtitles
&& !group_exists(g, MediaType::Subtitles)
{
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: format!("EXT-X-STREAM-INF SUBTITLES group {g:?} not found"),
});
}
if let Some(ClosedCaptionsRef::Group(g)) = &v.closed_captions
&& !group_exists(g, MediaType::ClosedCaptions)
{
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: format!("EXT-X-STREAM-INF CLOSED-CAPTIONS group {g:?} not found"),
});
}
}
for sk in &self.session_keys {
push_session_key_line(&mut String::new(), sk)?;
}
for sd in &self.session_data {
push_session_data_line(&mut String::new(), sd)?;
}
Ok(())
}
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 media: Vec<MediaRendition> = 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_int(rest, line_no, line, "EXT-X-VERSION")?;
} else if let Some(rest) = line.strip_prefix("#EXT-X-STREAM-INF:") {
let (attrs, quoted) = parse_attribute_list_strict(rest)?;
let bandwidth_str =
require_attr(&attrs, "BANDWIDTH", line_no, line, "EXT-X-STREAM-INF")?;
let bandwidth = parse_decimal_int(&bandwidth_str, line_no, line, "BANDWIDTH")?;
let average_bandwidth = match attrs.get("AVERAGE-BANDWIDTH") {
Some(v) => Some(parse_decimal_int(v, line_no, line, "AVERAGE-BANDWIDTH")?),
None => None,
};
let frame_rate = match attrs.get("FRAME-RATE") {
Some(v) => Some(parse_frame_rate(v, line_no, line)?),
None => None,
};
let codecs = attrs.get("CODECS").cloned();
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",
"AVERAGE-BANDWIDTH",
"CODECS",
"RESOLUTION",
"FRAME-RATE",
],
"ed,
)?;
if pending_stream_inf.is_some() {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "second #EXT-X-STREAM-INF before the first Variant's URI \
line (RFC 8216bis §4.4.6.2)"
.to_string(),
});
}
pending_stream_inf = Some((
bandwidth,
average_bandwidth,
frame_rate,
codecs,
resolution,
extra_attrs,
));
} else if let Some(rest) = line.strip_prefix("#EXT-X-I-FRAME-STREAM-INF:") {
let (attrs, quoted) = parse_attribute_list_strict(rest)?;
let bandwidth_str = require_attr(
&attrs,
"BANDWIDTH",
line_no,
line,
"EXT-X-I-FRAME-STREAM-INF",
)?;
let bandwidth = parse_decimal_int(&bandwidth_str, line_no, line, "BANDWIDTH")?;
let average_bandwidth = match attrs.get("AVERAGE-BANDWIDTH") {
Some(v) => Some(parse_decimal_int(v, line_no, line, "AVERAGE-BANDWIDTH")?),
None => None,
};
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",
"AVERAGE-BANDWIDTH",
"CODECS",
"RESOLUTION",
"URI",
],
"ed,
)?;
iframe_variants.push(IFrameVariant {
bandwidth,
average_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, quoted) = parse_attribute_list_strict(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"], "ed)?;
content_steering = Some(ContentSteering {
server_uri,
pathway_id,
extra_attrs,
});
} else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA:") {
media.push(parse_media_rendition(rest, line_no, line)?);
} 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, average_bandwidth, frame_rate, 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(),
})?;
let mut audio = None;
let mut video = None;
let mut subtitles = None;
let mut closed_captions = None;
let mut extra_attrs_filtered = Vec::new();
for (k, v) in extra_attrs {
match k.as_str() {
"AUDIO" => audio = Some(v.as_str().to_string()),
"VIDEO" => video = Some(v.as_str().to_string()),
"SUBTITLES" => subtitles = Some(v.as_str().to_string()),
"CLOSED-CAPTIONS" => {
if v.as_str() == "NONE" {
closed_captions = Some(ClosedCaptionsRef::None);
} else {
closed_captions =
Some(ClosedCaptionsRef::Group(v.as_str().to_string()));
}
}
_ => extra_attrs_filtered.push((k, v)),
}
}
variants.push(Variant {
bandwidth,
average_bandwidth,
frame_rate,
codecs,
resolution,
uri: line.to_string(),
audio,
video,
subtitles,
closed_captions,
extra_attrs: extra_attrs_filtered,
});
}
}
if !saw_extm3u {
return Err(Error::HlsParse {
line_no: 1,
line: String::new(),
reason: "missing #EXTM3U header".to_string(),
});
}
if pending_stream_inf.is_some() {
return Err(Error::HlsParse {
line_no: 0,
line: String::new(),
reason: "#EXT-X-STREAM-INF with no following Variant URI line (RFC 8216bis \
§4.4.6.2)"
.to_string(),
});
}
check_rendition_groups(&media)?;
Ok(MasterPlaylist {
version,
media,
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);
for m in &self.media {
if let Some(InstreamId::Service(_)) = m.instream_id {
bump_version(&mut v, VERSION_MEDIA_SERVICE_INSTREAM_ID);
}
if m.instream_id.is_some() && m.media_type != MediaType::ClosedCaptions {
bump_version(&mut v, VERSION_MEDIA_INSTREAM_ID_NON_CC);
}
}
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.media.iter().any(|m| {
m.uri
.as_ref()
.is_some_and(|u| contains_variable_substitution(u))
|| contains_variable_substitution(&m.group_id)
|| contains_variable_substitution(&m.name)
|| m.language
.as_ref()
.is_some_and(|x| contains_variable_substitution(x))
|| m.assoc_language
.as_ref()
.is_some_and(|x| contains_variable_substitution(x))
|| m.stable_rendition_id
.as_ref()
.is_some_and(|x| contains_variable_substitution(x))
|| m.channels
.as_ref()
.is_some_and(|x| contains_variable_substitution(x))
|| m.characteristics
.iter()
.any(|x| contains_variable_substitution(x))
})
|| 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.media,
&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_frame_rate(v: &str, line_no: usize, line: &str) -> Result<FrameRate> {
let t = v.trim();
if !is_valid_decimal_token(t, false) {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("FRAME-RATE value {v:?} is not a valid decimal number"),
});
}
let (int_part, frac_part) = match t.split_once('.') {
Some((i, f)) => (i, Some(f)),
None => (t, None),
};
let out_of_range = || Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("FRAME-RATE value {v:?} is out of range"),
};
let int_value: u32 = int_part.parse().map_err(|_| out_of_range())?;
let mut digits = frac_part.unwrap_or("").bytes().map(|b| u32::from(b - b'0'));
let mut next = || digits.next().unwrap_or(0);
let milli = next() * 100 + next() * 10 + next();
let round_up = u32::from(next() >= 5);
let total = int_value
.checked_mul(1000)
.and_then(|i| i.checked_add(milli + round_up))
.ok_or_else(out_of_range)?;
Ok(FrameRate::from_millihertz(total))
}
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_int(w, line_no, line, "RESOLUTION width")?;
let height = parse_decimal_int(h, line_no, line, "RESOLUTION height")?;
Ok((width, height))
}
fn parse_yes_no(attrs: &BTreeMap<String, String>, attribute: &'static str) -> Result<Option<bool>> {
match attrs.get(attribute).map(String::as_str) {
None => Ok(None),
Some("YES") => Ok(Some(true)),
Some("NO") => Ok(Some(false)),
Some(other) => Err(Error::InvalidYesNo {
attribute,
value: other.to_string(),
}),
}
}
fn check_rendition_groups(media: &[MediaRendition]) -> Result<()> {
let mut seen: BTreeSet<(&str, &str, &str)> = BTreeSet::new();
let mut defaults: BTreeSet<(&str, &str)> = BTreeSet::new();
for m in media {
let group = (m.media_type.name(), m.group_id.as_str());
if !seen.insert((m.media_type.name(), m.group_id.as_str(), m.name.as_str())) {
return Err(Error::DuplicateNameInGroup {
media_type: m.media_type,
group_id: m.group_id.clone(),
name: m.name.clone(),
});
}
if m.default && !defaults.insert(group) {
return Err(Error::DuplicateDefaultInGroup {
media_type: m.media_type,
group_id: m.group_id.clone(),
});
}
}
Ok(())
}
fn parse_media_rendition(rest: &str, line_no: usize, line: &str) -> Result<MediaRendition> {
let (attrs, quoted) = parse_attribute_list_strict(rest)?;
let type_str = require_attr(&attrs, "TYPE", line_no, line, "EXT-X-MEDIA")?;
let media_type = match type_str.as_str() {
"AUDIO" => MediaType::Audio,
"VIDEO" => MediaType::Video,
"SUBTITLES" => MediaType::Subtitles,
"CLOSED-CAPTIONS" => MediaType::ClosedCaptions,
_ => {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: format!("EXT-X-MEDIA invalid TYPE {type_str:?}"),
});
}
};
let group_id = require_attr(&attrs, "GROUP-ID", line_no, line, "EXT-X-MEDIA")?;
if group_id.is_empty() {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA GROUP-ID must be non-empty".to_string(),
});
}
validate_quoted_string(&group_id, "EXT-X-MEDIA GROUP-ID")?;
let name = require_attr(&attrs, "NAME", line_no, line, "EXT-X-MEDIA")?;
if name.is_empty() {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA NAME must be non-empty".to_string(),
});
}
validate_quoted_string(&name, "EXT-X-MEDIA NAME")?;
let uri = attrs.get("URI").cloned();
if media_type == MediaType::ClosedCaptions && uri.is_some() {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA URI MUST NOT be present when TYPE is CLOSED-CAPTIONS".to_string(),
});
}
if let Some(u) = &uri {
validate_uri(u, "EXT-X-MEDIA URI")?;
}
let language = attrs.get("LANGUAGE").cloned();
let assoc_language = attrs.get("ASSOC-LANGUAGE").cloned();
if let Some(l) = &language {
validate_quoted_string(l, "EXT-X-MEDIA LANGUAGE")?;
}
if let Some(l) = &assoc_language {
validate_quoted_string(l, "EXT-X-MEDIA ASSOC-LANGUAGE")?;
}
let stable_rendition_id = attrs.get("STABLE-RENDITION-ID").cloned();
if let Some(s) = &stable_rendition_id {
validate_quoted_string(s, "EXT-X-MEDIA STABLE-RENDITION-ID")?;
}
let default = parse_yes_no(&attrs, "DEFAULT")?.unwrap_or(false);
let autoselect_attr = parse_yes_no(&attrs, "AUTOSELECT")?;
let autoselect = autoselect_attr.unwrap_or(false);
if default && autoselect_attr == Some(false) {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA AUTOSELECT MUST be YES when DEFAULT is YES".to_string(),
});
}
let forced = parse_yes_no(&attrs, "FORCED")?.unwrap_or(false);
if forced && media_type != MediaType::Subtitles {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES".to_string(),
});
}
if attrs.contains_key("FORCED") && media_type != MediaType::Subtitles {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA FORCED MUST NOT be present unless TYPE is SUBTITLES".to_string(),
});
}
let instream_id = if let Some(v) = attrs.get("INSTREAM-ID") {
validate_quoted_string(v, "EXT-X-MEDIA INSTREAM-ID")?;
if media_type == MediaType::ClosedCaptions {
Some(InstreamId::parse_closed_captions(v)?)
} else {
Some(InstreamId::Other(v.clone()))
}
} else {
None
};
if media_type == MediaType::ClosedCaptions && instream_id.is_none() {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA INSTREAM-ID REQUIRED when TYPE is CLOSED-CAPTIONS".to_string(),
});
}
let bit_depth = if let Some(v) = attrs.get("BIT-DEPTH") {
if media_type != MediaType::Audio {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA BIT-DEPTH MUST NOT be present unless TYPE is AUDIO"
.to_string(),
});
}
Some(parse_decimal_int(v, line_no, line, "BIT-DEPTH")?)
} else {
None
};
let sample_rate = if let Some(v) = attrs.get("SAMPLE-RATE") {
if media_type != MediaType::Audio {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA SAMPLE-RATE MUST NOT be present unless TYPE is AUDIO"
.to_string(),
});
}
Some(parse_decimal_int(v, line_no, line, "SAMPLE-RATE")?)
} else {
None
};
let channels = if let Some(v) = attrs.get("CHANNELS") {
if media_type != MediaType::Audio {
return Err(Error::HlsParse {
line_no,
line: line.to_string(),
reason: "EXT-X-MEDIA CHANNELS MUST NOT be present unless TYPE is AUDIO".to_string(),
});
}
validate_quoted_string(v, "EXT-X-MEDIA CHANNELS")?;
Some(v.clone())
} else {
None
};
let characteristics = attrs
.get("CHARACTERISTICS")
.map(|v| {
validate_quoted_string(v, "EXT-X-MEDIA CHARACTERISTICS")?;
Ok(v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>())
})
.transpose()?
.unwrap_or_default();
const KNOWN: &[&str] = &[
"TYPE",
"URI",
"GROUP-ID",
"LANGUAGE",
"ASSOC-LANGUAGE",
"NAME",
"STABLE-RENDITION-ID",
"DEFAULT",
"AUTOSELECT",
"FORCED",
"INSTREAM-ID",
"BIT-DEPTH",
"SAMPLE-RATE",
"CHARACTERISTICS",
"CHANNELS",
];
let extra_attrs = filter_extra_attrs(&attrs, KNOWN, "ed)?;
Ok(MediaRendition {
media_type,
uri,
group_id,
language,
assoc_language,
name,
stable_rendition_id,
default,
autoselect,
forced,
instream_id,
bit_depth,
sample_rate,
characteristics,
channels,
extra_attrs,
})
}
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: DecimalSeconds::new(duration).unwrap(),
discontinuous: false,
parts: vec![],
..Default::default()
}
}
fn seg_disc(uri: &str, duration: f64) -> MediaSegment {
MediaSegment {
uri: uri.into(),
duration: DecimalSeconds::new(duration).unwrap(),
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![],
low_latency: None,
iframes_only: false,
open_segment: None,
..Default::default()
};
let out = pl.to_m3u8().unwrap();
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-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().unwrap();
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,
media: vec![],
variants: vec![
Variant {
bandwidth: 300_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("avc1.64001e,mp4a.40.2".into()),
resolution: Some((640, 360)),
uri: "v300/index.m3u8".into(),
..Default::default()
},
Variant {
bandwidth: 800_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("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().unwrap();
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,
media: vec![],
variants: vec![Variant {
bandwidth: 1_000_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("avc1.640028".into()),
resolution: None,
uri: "v1k/index.m3u8".into(),
audio: None,
video: None,
subtitles: None,
closed_captions: None,
extra_attrs: Vec::new(),
}],
iframe_variants: vec![],
..Default::default()
};
let out = pl.to_m3u8().unwrap();
assert!(!out.contains("RESOLUTION"));
assert!(out.contains("#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.640028\""));
}
#[test]
fn master_playlist_renders_average_bandwidth_after_bandwidth() {
let pl = MasterPlaylist {
media: vec![],
variants: vec![Variant {
bandwidth: 2_523_597,
average_bandwidth: Some(2_190_673),
frame_rate: None,
codecs: Some("avc1.640020,mp4a.40.2".into()),
uri: "v540/index.m3u8".into(),
..Default::default()
}],
..Default::default()
};
let out = pl.to_m3u8().unwrap();
assert!(
out.contains(
"#EXT-X-STREAM-INF:BANDWIDTH=2523597,AVERAGE-BANDWIDTH=2190673,\
CODECS=\"avc1.640020,mp4a.40.2\""
),
"out: {out}"
);
let reparsed = MasterPlaylist::parse(&out).unwrap();
assert_eq!(reparsed.variants, pl.variants, "render -> parse must equal");
assert_eq!(reparsed.variants[0].average_bandwidth, Some(2_190_673));
}
#[test]
fn master_playlist_omits_absent_average_bandwidth_and_round_trips_none() {
let pl = MasterPlaylist {
media: vec![],
variants: vec![Variant {
bandwidth: 800_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("avc1.640028,mp4a.40.2".into()),
uri: "v800/index.m3u8".into(),
..Default::default()
}],
..Default::default()
};
let out = pl.to_m3u8().unwrap();
assert!(!out.contains("AVERAGE-BANDWIDTH"), "out: {out}");
let reparsed = MasterPlaylist::parse(&out).unwrap();
assert_eq!(reparsed.variants, pl.variants, "render -> parse must equal");
assert_eq!(reparsed.variants[0].average_bandwidth, None);
}
#[test]
fn average_bandwidth_must_be_a_decimal_integer() {
let text =
"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=12x\nv/index.m3u8\n";
let err = MasterPlaylist::parse(text).unwrap_err();
match err {
Error::HlsParse { reason, .. } => {
assert!(reason.contains("AVERAGE-BANDWIDTH"), "reason: {reason}");
}
other => panic!("expected HlsParse, got {other:?}"),
}
}
#[test]
fn iframe_variant_renders_average_bandwidth_after_bandwidth() {
let pl = MasterPlaylist {
iframe_variants: vec![IFrameVariant {
bandwidth: 200_000,
average_bandwidth: Some(150_000),
codecs: Some("hvc1.1.6.L93.B0".into()),
resolution: Some((320, 240)),
uri: "iframe.m3u8".into(),
extra_attrs: Vec::new(),
}],
..Default::default()
};
let out = pl.to_m3u8().unwrap();
assert!(
out.contains("#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=200000,AVERAGE-BANDWIDTH=150000,"),
"out: {out}"
);
let reparsed = MasterPlaylist::parse(&out).unwrap();
assert_eq!(reparsed.iframe_variants, pl.iframe_variants);
assert_eq!(reparsed.iframe_variants[0].average_bandwidth, Some(150_000));
}
#[test]
fn iframe_variant_omits_absent_average_bandwidth_and_round_trips_none() {
let pl = MasterPlaylist {
iframe_variants: vec![IFrameVariant {
bandwidth: 200_000,
average_bandwidth: None,
codecs: Some("hvc1.1.6.L93.B0".into()),
resolution: Some((320, 240)),
uri: "iframe.m3u8".into(),
extra_attrs: Vec::new(),
}],
..Default::default()
};
let out = pl.to_m3u8().unwrap();
assert!(!out.contains("AVERAGE-BANDWIDTH"), "out: {out}");
let reparsed = MasterPlaylist::parse(&out).unwrap();
assert_eq!(reparsed.iframe_variants, pl.iframe_variants);
assert_eq!(reparsed.iframe_variants[0].average_bandwidth, None);
}
#[test]
fn iframe_variant_average_bandwidth_not_retained_in_extra_attrs() {
let text = "#EXTM3U\n#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=1200,URI=\"i.m3u8\"\n";
let pl = MasterPlaylist::parse(text).unwrap();
assert_eq!(pl.iframe_variants[0].average_bandwidth, Some(1200));
assert!(
pl.iframe_variants[0].extra_attrs.is_empty(),
"extra_attrs: {:?}",
pl.iframe_variants[0].extra_attrs
);
assert!(
!pl.iframe_variants[0]
.extra_attrs
.iter()
.any(|(k, _)| k == "AVERAGE-BANDWIDTH")
);
}
#[test]
fn iframe_average_bandwidth_must_be_a_decimal_integer() {
let text = "#EXTM3U\n#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=1000,AVERAGE-BANDWIDTH=12x,URI=\"i.m3u8\"\n";
let err = MasterPlaylist::parse(text).unwrap_err();
match err {
Error::HlsParse { reason, .. } => {
assert!(reason.contains("AVERAGE-BANDWIDTH"), "reason: {reason}");
}
other => panic!("expected HlsParse, got {other:?}"),
}
}
#[test]
fn master_playlist_renders_frame_rate_after_resolution() {
let pl = MasterPlaylist {
media: vec![],
variants: vec![Variant {
bandwidth: 1_000_000,
average_bandwidth: None,
codecs: Some("avc1.640028".into()),
resolution: Some((1280, 720)),
frame_rate: Some(FrameRate::from_millihertz(29_970)),
uri: "v/index.m3u8".into(),
..Default::default()
}],
..Default::default()
};
let out = pl.to_m3u8().unwrap();
assert!(
out.contains("RESOLUTION=1280x720,FRAME-RATE=29.970"),
"out: {out}"
);
let reparsed = MasterPlaylist::parse(&out).unwrap();
assert_eq!(reparsed.variants, pl.variants);
assert_eq!(
reparsed.variants[0].frame_rate,
Some(FrameRate::from_millihertz(29_970))
);
}
#[test]
fn master_playlist_omits_absent_frame_rate_and_round_trips_none() {
let pl = MasterPlaylist {
media: vec![],
variants: vec![Variant {
bandwidth: 800_000,
average_bandwidth: None,
codecs: Some("avc1.640028".into()),
resolution: None,
frame_rate: None,
uri: "v/index.m3u8".into(),
..Default::default()
}],
..Default::default()
};
let out = pl.to_m3u8().unwrap();
assert!(!out.contains("FRAME-RATE"), "out: {out}");
let reparsed = MasterPlaylist::parse(&out).unwrap();
assert_eq!(reparsed.variants[0].frame_rate, None);
}
#[test]
fn frame_rate_parses_various_forms() {
for (text, expected) in [
("30", 30_000u32),
("29.97", 29_970u32),
("29.9704", 29_970u32),
] {
let src = format!(
"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,FRAME-RATE={text}\nv/index.m3u8\n"
);
let parsed = MasterPlaylist::parse(&src).unwrap();
assert_eq!(
parsed.variants[0].frame_rate,
Some(FrameRate::from_millihertz(expected)),
"text={text}"
);
}
}
#[test]
fn frame_rate_rejects_invalid_values() {
for value in ["", "-1", "1e3", "abc", "1.2.3"] {
let src = format!(
"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,FRAME-RATE={value}\nv/index.m3u8\n"
);
let err = MasterPlaylist::parse(&src).unwrap_err();
match err {
Error::HlsParse { reason, .. } => {
assert!(
reason.contains("FRAME-RATE"),
"value={value:?} reason={reason}"
);
}
other => panic!("value={value:?} expected HlsParse, got {other:?}"),
}
}
}
#[test]
fn extinf_three_decimals() {
let pl = playlist(vec![seg("s.m4s", 9.009)]);
let out = pl.to_m3u8().unwrap();
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().unwrap();
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,
DecimalSeconds::new(9.0).unwrap()
);
}
#[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().unwrap();
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.get(), duration, "{out}");
}
}
#[test]
fn sub_millisecond_durations_survive_rendering() {
let pl = playlist(vec![seg("main.ts", 9.9766)]);
let out = pl.to_m3u8().unwrap();
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,
DecimalSeconds::new(9.9766).unwrap(),
"duration must survive a round trip bit-exactly"
);
assert_eq!(format_secs(DecimalSeconds::new(0.334).unwrap()), "0.334");
assert_eq!(format_secs(DecimalSeconds::new(1.5).unwrap()), "1.5");
assert_eq!(format_secs(DecimalSeconds::new(6.0).unwrap()), "6");
assert_eq!(
format_secs(DecimalSeconds::new(2.00004).unwrap()),
"2.00004"
);
assert_eq!(
format_secs(DecimalSeconds::new(4.00008).unwrap()),
"4.00008"
);
assert_eq!(
format_signed_secs(SignedDecimalSeconds::new(-10.5).unwrap()),
"-10.5"
);
assert_eq!(
format_signed_secs(SignedDecimalSeconds::new(-2.00004).unwrap()),
"-2.00004"
);
}
#[test]
fn extinf_and_ll_hls_durations_reject_non_conformant_decimal_tokens() {
for bad in ["nan", "inf", "-inf", "infinity", "1e10", "+3", "-5", ""] {
let pl = format!("#EXTM3U\n#EXTINF:{bad},\nmain.ts\n");
assert!(
matches!(
MediaPlaylist::parse(&pl),
Err(crate::error::Error::HlsParse { .. })
),
"EXTINF:{bad} must be rejected"
);
}
for bad in ["nan", "inf", "1e5"] {
let pl = format!("#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-START:TIME-OFFSET={bad}\n");
assert!(
matches!(
MediaPlaylist::parse(&pl),
Err(crate::error::Error::HlsParse { .. })
),
"TIME-OFFSET={bad} must be rejected"
);
}
let pl = "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-START:TIME-OFFSET=-12.5\n";
assert_eq!(
MediaPlaylist::parse(pl).unwrap().start.unwrap().time_offset,
SignedDecimalSeconds::new(-12.5).unwrap()
);
}
#[test]
fn decimal_seconds_rejects_non_finite_and_negative() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
assert!(
matches!(
DecimalSeconds::new(bad),
Err(crate::error::Error::InvalidDecimalSeconds(..))
),
"DecimalSeconds::new({bad}) must be rejected"
);
}
assert_eq!(DecimalSeconds::new(6.0).unwrap().get(), 6.0);
}
#[test]
fn signed_decimal_seconds_rejects_non_finite_but_allows_negative() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(
matches!(
SignedDecimalSeconds::new(bad),
Err(crate::error::Error::InvalidSignedDecimalSeconds(..))
),
"SignedDecimalSeconds::new({bad}) must be rejected"
);
}
assert_eq!(SignedDecimalSeconds::new(-6.0).unwrap().get(), -6.0);
}
#[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: DecimalSeconds::new(4.00008).unwrap(),
parts: vec![
PartSpec {
uri: "filePart271.0.mp4".into(),
duration: DecimalSeconds::new(2.00004).unwrap(),
independent: true,
..Default::default()
},
PartSpec {
uri: "filePart271.1.mp4".into(),
duration: DecimalSeconds::new(0.50001).unwrap(),
..Default::default()
},
],
..Default::default()
}],
low_latency: Some(LowLatencyConfig {
part_target: Some(DecimalSeconds::new(2.00002).unwrap()),
part_hold_back: Some(DecimalSeconds::new(6.00006).unwrap()),
..Default::default()
}),
..Default::default()
};
let text = pl.to_m3u8().unwrap();
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().unwrap();
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().unwrap();
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().unwrap();
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().unwrap();
assert!(
!out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
"header must be absent when n==0"
);
}
fn ll_config() -> LowLatencyConfig {
LowLatencyConfig {
part_target: Some(DecimalSeconds::new(0.5).unwrap()),
part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
preload_hint_part: None,
..Default::default()
}
}
#[test]
fn server_control_without_part_target_keeps_part_hold_back() {
let pl = MediaPlaylist {
target_duration: 4,
low_latency: Some(LowLatencyConfig {
part_target: None,
part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
hold_back: Some(DecimalSeconds::new(12.0).unwrap()),
..Default::default()
}),
..Default::default()
};
let out = pl.to_m3u8().unwrap();
assert!(out.contains("PART-HOLD-BACK=1.5"), "{out}");
assert!(!out.contains("#EXT-X-PART-INF"), "{out}");
}
#[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: DecimalSeconds::new(4.0).unwrap(),
discontinuous: false,
parts: vec![PartSpec {
uri: "part-1-1.m4s".into(),
duration: DecimalSeconds::new(0.5).unwrap(),
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().unwrap();
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: DecimalSeconds::new(4.0).unwrap(),
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: DecimalSeconds::new(0.5).unwrap(),
independent: true,
..Default::default()
}])),
..Default::default()
};
let out = pl.to_m3u8().unwrap();
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_map_does_not_count_toward_version_without_low_latency() {
let mut open = OpenSegment::new(vec![PartSpec {
uri: "part-1-5.0.m4s".into(),
duration: DecimalSeconds::new(0.5).unwrap(),
independent: true,
..Default::default()
}]);
open.map = Some(MapTag {
uri: "init.mp4".into(),
byte_range: None,
extra_attrs: Vec::new(),
});
let mut pl = MediaPlaylist {
target_duration: 4,
segments: vec![seg("seg-1-4.m4s", 4.0)],
open_segment: Some(open),
..Default::default()
};
assert_eq!(
pl.computed_version(),
None,
"map not rendered, no version row"
);
pl.low_latency = Some(ll_config());
assert!(pl.computed_version() >= Some(6));
}
#[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: DecimalSeconds::new(0.5).unwrap(),
independent: true,
..Default::default()
}])),
..Default::default()
};
let out = pl.to_m3u8().unwrap();
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().unwrap();
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: DecimalSeconds::new(4.0).unwrap(),
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: DecimalSeconds::new(0.5).unwrap(),
independent: true,
..Default::default()
}])),
..Default::default()
};
let out = pl.to_m3u8().unwrap();
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: DecimalSeconds::new(0.5).unwrap(),
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().unwrap();
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: Some(DecimalSeconds::new(0.5).unwrap()),
part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()), 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(DecimalSeconds::new(24.0).unwrap()),
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: DecimalSeconds::new(4.0).unwrap(),
discontinuous: false,
parts: vec![
PartSpec {
uri: "part-9.0.m4s".into(),
duration: DecimalSeconds::new(0.5).unwrap(),
independent: true,
byte_range: None,
gap: false,
..Default::default()
},
PartSpec {
uri: "part-9.1.m4s".into(),
duration: DecimalSeconds::new(0.5).unwrap(),
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: DecimalSeconds::new(0.5).unwrap(),
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().unwrap();
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: DecimalSeconds::new(10.0).unwrap(),
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: DecimalSeconds::new(10.0).unwrap(),
discontinuous: false,
parts: vec![],
byte_range: Some(ByteRange {
length: 500_000,
offset: None,
}),
map: Some(map.clone()),
pre_tags: vec![
"#EXT-X-DATERANGE:ID=\"ad-1\",START-DATE=\"2024-01-01T00:00:00.000Z\",DURATION=15.0"
.into(),
],
..Default::default()
},
],
open_segment: None,
endlist: true,
low_latency: None,
iframes_only: false,
rendition_reports: vec![],
skip: None,
..Default::default()
};
let text = pl.to_m3u8().unwrap();
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: SignedDecimalSeconds::new(5.5).unwrap(),
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: DecimalSeconds::new(10.0).unwrap(),
bitrate: Some(2000),
..Default::default()
},
MediaSegment {
uri: "seg1.ts".into(),
duration: DecimalSeconds::new(10.0).unwrap(),
gap: true,
bitrate: Some(2000),
..Default::default()
},
MediaSegment {
uri: "seg2.ts".into(),
duration: DecimalSeconds::new(10.0).unwrap(),
bitrate: Some(1800),
..Default::default()
},
],
endlist: true,
..Default::default()
};
let text = pl.to_m3u8().unwrap();
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::InvalidIv { value } if value == "0xnotahexvalue"));
}
#[test]
fn round_trip_multivariant_playlist() {
let pl = MasterPlaylist {
version: 7,
media: vec![],
variants: vec![
Variant {
bandwidth: 300_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("avc1.64001e,mp4a.40.2".into()),
resolution: Some((640, 360)),
uri: "v300/index.m3u8".into(),
..Default::default()
},
Variant {
bandwidth: 800_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("avc1.640028,mp4a.40.2".into()),
resolution: Some((1280, 720)),
uri: "v800/index.m3u8".into(),
..Default::default()
},
],
iframe_variants: vec![IFrameVariant {
bandwidth: 50_000,
average_bandwidth: None,
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().unwrap();
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: SignedDecimalSeconds::new(-10.5).unwrap(),
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(),
}),
media: vec![],
variants: vec![Variant {
bandwidth: 1_280_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("avc1.64001e,mp4a.40.2".into()),
resolution: Some((640, 360)),
uri: "low/index.m3u8".into(),
audio: None,
video: None,
subtitles: None,
closed_captions: None,
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().unwrap();
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(DecimalSeconds::new(24.0).unwrap()));
assert!(!pl.endlist);
}
#[test]
fn skip_recently_removed_ids_reject_injection() {
let text = "#EXTM3U\n#EXT-X-VERSION:10\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:0\n\
#EXT-X-SKIP:SKIPPED-SEGMENTS=3,RECENTLY-REMOVED-DATERANGES=\"ad-1\\tad-2\"\n\
#EXTINF:4,\na.mp4\n"
.replace("\\t", "\t");
let good = MediaPlaylist::parse(&text).unwrap();
assert_eq!(good.to_m3u8().unwrap(), text);
for bad in ["a\"b", "a\rb", "a\nb", "a\tb"] {
let mut pl = good.clone();
pl.skip.as_mut().unwrap().recently_removed_daterange_ids = vec![bad.to_string()];
assert!(
matches!(pl.to_m3u8(), Err(Error::InvalidQuotedString { .. })),
"{bad:?}"
);
}
}
#[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: DecimalSeconds::new(4.0).unwrap(),
..Default::default()
}],
low_latency: Some(LowLatencyConfig {
part_target: Some(DecimalSeconds::new(0.5).unwrap()),
part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
can_block_reload: false,
..Default::default()
}),
..Default::default()
};
let text = pl.to_m3u8().unwrap();
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.segments
.first()
.is_some_and(|s| s.program_date_time.is_some()),
"EXT-X-PROGRAM-DATE-TIME must be preserved as a typed field, not dropped: {:?}",
pl.segments.first().map(|s| s.program_date_time)
);
}
#[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 else {
panic!("expected Error::HlsParse, got {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_models_ext_x_media() {
let text = "#EXTM3U\n\
#EXT-X-VERSION:7\n\
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\",NAME=\"English\",DEFAULT=YES,AUTOSELECT=YES,URI=\"eng.m3u8\"\n\
#EXT-X-STREAM-INF:BANDWIDTH=300000,CODECS=\"avc1.64001e,mp4a.40.2\",AUDIO=\"aac\"\n\
v300/index.m3u8\n";
let pl = MasterPlaylist::parse(text).expect("EXT-X-MEDIA must parse");
assert_eq!(pl.variants.len(), 1);
assert_eq!(pl.media.len(), 1);
assert_eq!(pl.media[0].name, "English");
}
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().unwrap();
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().unwrap();
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().unwrap();
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().unwrap();
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().unwrap();
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().unwrap();
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().unwrap();
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().unwrap();
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().unwrap();
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,
media: vec![],
variants: vec![Variant {
bandwidth: 300_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("avc1.64001e".into()),
resolution: None,
uri: "v300/index.m3u8".into(),
audio: None,
video: None,
subtitles: None,
closed_captions: None,
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().unwrap();
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().unwrap();
assert_eq!(rendered_version(&out), Some(8), "{out}");
}
#[test]
fn version_row8_variable_substitution_triggers_v8_multivariant() {
let pl = MasterPlaylist {
version: 0,
media: vec![],
variants: vec![Variant {
bandwidth: 300_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("avc1.64001e".into()),
resolution: None,
uri: "{$base}/index.m3u8".into(),
audio: None,
video: None,
subtitles: None,
closed_captions: None,
extra_attrs: Vec::new(),
}],
iframe_variants: vec![],
extra_tags: vec![],
..Default::default()
};
let out = pl.to_m3u8().unwrap();
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().unwrap();
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().unwrap();
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().unwrap();
assert_eq!(rendered_version(&out), Some(11), "{out}");
}
#[test]
fn version_row11_define_queryparam_triggers_v11_multivariant() {
let pl = MasterPlaylist {
version: 0,
media: vec![],
variants: vec![Variant {
bandwidth: 300_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("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().unwrap();
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().unwrap();
assert_eq!(rendered_version(&out), Some(12), "{out}");
}
#[test]
fn version_row12_req_attribute_in_per_tag_lists_triggers_v12() {
let mut pl = base_media_playlist();
pl.low_latency = Some(LowLatencyConfig {
sc_extra_attrs: vec![(
"REQ-LATENCY".to_string(),
AttrValue::bare("ultra-low").unwrap(),
)],
..Default::default()
});
let out = pl.to_m3u8().unwrap();
assert!(out.contains("REQ-LATENCY=ultra-low"), "{out}");
assert_eq!(rendered_version(&out), Some(12), "{out}");
for (list, name) in [("pi", "REQ-VIDEO"), ("ph", "REQ-INFO")] {
let mut pl = base_media_playlist();
let attr = vec![(name.to_string(), AttrValue::bare("1").unwrap())];
let mut ll = LowLatencyConfig::default();
match list {
"pi" => ll.pi_extra_attrs = attr,
_ => ll.ph_extra_attrs = attr,
}
pl.low_latency = Some(ll);
let out = pl.to_m3u8().unwrap();
assert_eq!(rendered_version(&out), Some(12), "{list}: {out}");
}
}
#[test]
fn version_row12_req_attribute_triggers_v12_multivariant() {
let pl = MasterPlaylist {
version: 0,
media: vec![],
variants: vec![Variant {
bandwidth: 300_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("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().unwrap();
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,
media: vec![],
variants: vec![Variant {
bandwidth: 300_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("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().unwrap();
assert_eq!(rendered_version(&out), Some(13), "{out}");
}
#[test]
fn version_multivariant_no_trigger_omits_the_tag() {
let pl = MasterPlaylist {
version: 0,
media: vec![],
variants: vec![Variant {
bandwidth: 300_000,
average_bandwidth: None,
frame_rate: None,
codecs: Some("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().unwrap();
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: DecimalSeconds::new(4.0).unwrap(),
..Default::default()
}],
open_segment: Some(OpenSegment::new(vec![PartSpec {
uri: "part-1-101.0.m4s".into(),
duration: DecimalSeconds::new(0.5).unwrap(),
independent: true,
..Default::default()
}])),
extra_tags: vec!["#EXT-X-MAP:URI=\"init-1.mp4\"".into()],
low_latency: Some(LowLatencyConfig {
part_target: Some(DecimalSeconds::new(0.5).unwrap()),
part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
..Default::default()
}),
iframes_only: false,
..Default::default()
};
let out = pl.to_m3u8().unwrap();
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().unwrap();
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().unwrap()),
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().unwrap();
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().unwrap();
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("valid key_uri")
.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().unwrap();
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().unwrap();
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: Some(DecimalSeconds::new(0.5).unwrap()),
part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
can_skip_until: None,
can_skip_dateranges: true,
can_block_reload: true,
..Default::default()
}),
..Default::default()
};
let out = pl.to_m3u8().unwrap();
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(DecimalSeconds::new(12.0).unwrap()),
"HOLD-BACK=12.0 must parse"
);
let round = pl.to_m3u8().unwrap();
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(DecimalSeconds::new(12.0).unwrap()),
"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: DecimalSeconds::new(4.0).unwrap(),
..Default::default()
}],
low_latency: Some(LowLatencyConfig {
part_target: Some(DecimalSeconds::new(0.5).unwrap()),
part_hold_back: Some(DecimalSeconds::new(1.5).unwrap()),
hold_back: None,
can_block_reload: true,
..Default::default()
}),
..Default::default()
};
let out = pl.to_m3u8().unwrap();
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 else {
panic!("expected Error::HlsParse, got {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().unwrap();
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(DecimalSeconds::new(12.0).unwrap()));
assert!(ll.can_skip_dateranges);
assert!(
ll.pi_extra_attrs
.iter()
.any(|(k, v)| k == "REQ-VIDEO" && v.as_str() == "720p"),
"REQ-VIDEO must survive in pi_extra_attrs"
);
assert!(
ll.sc_extra_attrs
.iter()
.any(|(k, v)| k == "REQ-LATENCY" && v.as_str() == "ultra-low"),
"REQ-LATENCY must survive in sc_extra_attrs"
);
assert!(ll.pi_extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
assert!(ll.sc_extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
let round = pl.to_m3u8().unwrap();
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(DecimalSeconds::new(12.0).unwrap()));
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"));
}
#[test]
fn byterange_offset_plus_length_overflow_rejected() {
let input = "#EXTM3U\n\
#EXT-X-TARGETDURATION:10\n\
#EXTINF:10,\n\
#EXT-X-BYTERANGE:1@18446744073709551615\n\
seg0.ts\n";
let err = MediaPlaylist::parse(input).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("overflows"), "expected overflow error: {msg}");
}
#[test]
fn byterange_exact_max_accepted() {
let input = "#EXTM3U\n\
#EXT-X-TARGETDURATION:10\n\
#EXTINF:10,\n\
#EXT-X-BYTERANGE:1@18446744073709551614\n\
seg0.ts\n";
let pl = MediaPlaylist::parse(input).expect("u64::MAX exactly must parse");
assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, 1);
assert_eq!(
pl.segments[0].byte_range.as_ref().unwrap().offset,
Some(u64::MAX - 1)
);
}
#[test]
fn byterange_no_offset_max_length_accepted() {
let input = "#EXTM3U\n\
#EXT-X-TARGETDURATION:10\n\
#EXTINF:10,\n\
#EXT-X-BYTERANGE:18446744073709551615\n\
seg0.ts\n";
let pl = MediaPlaylist::parse(input).expect("no offset means no overflow check");
assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, u64::MAX);
assert!(pl.segments[0].byte_range.as_ref().unwrap().offset.is_none());
}
#[test]
fn map_byterange_overflow_rejected() {
let input = "#EXTM3U\n\
#EXT-X-TARGETDURATION:10\n\
#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"1@18446744073709551615\"\n\
#EXTINF:10,\n\
seg0.ts\n";
let err = MediaPlaylist::parse(input).unwrap_err();
assert!(
err.to_string().contains("overflows"),
"MAP BYTERANGE overflow: {err}"
);
}
#[test]
fn part_byterange_overflow_rejected() {
let input = "#EXTM3U\n\
#EXT-X-TARGETDURATION:10\n\
#EXT-X-PART-INF:PART-TARGET=1.0\n\
#EXT-X-PART:URI=\"p0.m4s\",DURATION=1.0,BYTERANGE=\"1@18446744073709551615\"\n\
#EXTINF:10,\n\
seg0.ts\n";
let err = MediaPlaylist::parse(input).unwrap_err();
assert!(
err.to_string().contains("overflows"),
"PART BYTERANGE overflow: {err}"
);
}
#[test]
fn preload_hint_byterange_overflow_rejected() {
let input = "#EXTM3U\n\
#EXT-X-TARGETDURATION:10\n\
#EXT-X-PART-INF:PART-TARGET=1.0\n\
#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"next.m4s\",\
BYTERANGE-START=18446744073709551615,BYTERANGE-LENGTH=1\n\
#EXTINF:10,\n\
seg0.ts\n";
let err = MediaPlaylist::parse(input).unwrap_err();
assert!(
err.to_string().contains("overflows"),
"PRELOAD-HINT overflow: {err}"
);
}
}