use crate::error::{PackagerError, PackagerResult};
use std::collections::BTreeMap;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InterstitialRestriction {
Skip,
Jump,
}
impl InterstitialRestriction {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Skip => "SKIP",
Self::Jump => "JUMP",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateRangeClass {
AppleInterstitial,
Scte35,
Custom(String),
}
impl DateRangeClass {
#[must_use]
pub fn as_uri(&self) -> &str {
match self {
Self::AppleInterstitial => "com.apple.hls.interstitial",
Self::Scte35 => "com.apple.hls.scte35",
Self::Custom(uri) => uri.as_str(),
}
}
}
#[derive(Debug, Clone)]
pub struct HlsDateRange {
pub id: String,
pub class: Option<DateRangeClass>,
pub start_date: String,
pub end_date: Option<String>,
pub duration: Option<Duration>,
pub planned_duration: Option<Duration>,
pub end_on_next: bool,
pub client_attributes: BTreeMap<String, String>,
}
impl HlsDateRange {
#[must_use]
pub fn new(id: impl Into<String>, start_date: impl Into<String>) -> Self {
Self {
id: id.into(),
class: None,
start_date: start_date.into(),
end_date: None,
duration: None,
planned_duration: None,
end_on_next: false,
client_attributes: BTreeMap::new(),
}
}
pub fn set_client_attribute(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.client_attributes.insert(key.into(), value.into());
}
#[must_use]
pub fn to_m3u8_tag(&self) -> String {
let mut attrs = Vec::new();
attrs.push(format!("ID=\"{}\"", self.id));
if let Some(class) = &self.class {
attrs.push(format!("CLASS=\"{}\"", class.as_uri()));
}
attrs.push(format!("START-DATE=\"{}\"", self.start_date));
if let Some(end) = &self.end_date {
attrs.push(format!("END-DATE=\"{end}\""));
}
if let Some(dur) = self.duration {
attrs.push(format!("DURATION={:.6}", dur.as_secs_f64()));
}
if let Some(planned) = self.planned_duration {
attrs.push(format!("PLANNED-DURATION={:.6}", planned.as_secs_f64()));
}
if self.end_on_next {
attrs.push("END-ON-NEXT=YES".to_string());
}
for (key, val) in &self.client_attributes {
attrs.push(format!("{key}=\"{val}\""));
}
format!("#EXT-X-DATERANGE:{}", attrs.join(","))
}
}
#[derive(Debug, Clone)]
pub struct HlsInterstitial {
pub id: String,
pub start_date: String,
pub duration: Option<Duration>,
pub asset_uri: Option<String>,
pub asset_list_uri: Option<String>,
pub resume_offset: Option<Duration>,
pub restrictions: Vec<InterstitialRestriction>,
pub snap: bool,
pub extra_attributes: BTreeMap<String, String>,
}
impl HlsInterstitial {
#[must_use]
pub fn builder(id: impl Into<String>) -> HlsInterstitialBuilder {
HlsInterstitialBuilder::new(id)
}
#[must_use]
pub fn to_m3u8_tag(&self) -> String {
let mut date_range = HlsDateRange::new(self.id.clone(), self.start_date.clone());
date_range.class = Some(DateRangeClass::AppleInterstitial);
date_range.duration = self.duration;
if let Some(uri) = &self.asset_uri {
date_range.set_client_attribute("X-ASSET-URI", uri.clone());
}
if let Some(list) = &self.asset_list_uri {
date_range.set_client_attribute("X-ASSET-LIST", list.clone());
}
if let Some(offset) = self.resume_offset {
date_range
.set_client_attribute("X-RESUME-OFFSET", format!("{:.6}", offset.as_secs_f64()));
}
if !self.restrictions.is_empty() {
let restriction_str = self
.restrictions
.iter()
.map(|r| r.as_str())
.collect::<Vec<_>>()
.join(",");
date_range.set_client_attribute("X-RESTRICT", restriction_str);
}
if self.snap {
date_range.set_client_attribute("X-SNAP", "IN,OUT");
}
for (k, v) in &self.extra_attributes {
date_range.set_client_attribute(k.clone(), v.clone());
}
date_range.to_m3u8_tag()
}
pub fn validate(&self) -> PackagerResult<()> {
if self.id.is_empty() {
return Err(PackagerError::InvalidConfig(
"interstitial ID must not be empty".into(),
));
}
if self.asset_uri.is_none() && self.asset_list_uri.is_none() {
return Err(PackagerError::InvalidConfig(
"interstitial must have either asset_uri or asset_list_uri".into(),
));
}
if self.start_date.is_empty() {
return Err(PackagerError::InvalidConfig(
"interstitial start_date must not be empty".into(),
));
}
Ok(())
}
}
pub struct HlsInterstitialBuilder {
id: String,
start_date: String,
duration: Option<Duration>,
asset_uri: Option<String>,
asset_list_uri: Option<String>,
resume_offset: Option<Duration>,
restrictions: Vec<InterstitialRestriction>,
snap: bool,
extra_attributes: BTreeMap<String, String>,
}
impl HlsInterstitialBuilder {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
start_date: String::new(),
duration: None,
asset_uri: None,
asset_list_uri: None,
resume_offset: None,
restrictions: Vec::new(),
snap: false,
extra_attributes: BTreeMap::new(),
}
}
#[must_use]
pub fn start_date(mut self, date: impl Into<String>) -> Self {
self.start_date = date.into();
self
}
#[must_use]
pub fn duration(mut self, dur: Duration) -> Self {
self.duration = Some(dur);
self
}
#[must_use]
pub fn asset_uri(mut self, uri: impl Into<String>) -> Self {
self.asset_uri = Some(uri.into());
self
}
#[must_use]
pub fn asset_list_uri(mut self, uri: impl Into<String>) -> Self {
self.asset_list_uri = Some(uri.into());
self
}
#[must_use]
pub fn resume_offset(mut self, offset: Duration) -> Self {
self.resume_offset = Some(offset);
self
}
#[must_use]
pub fn restrict(mut self, restrictions: &[InterstitialRestriction]) -> Self {
self.restrictions = restrictions.to_vec();
self
}
#[must_use]
pub fn snap(mut self, snap: bool) -> Self {
self.snap = snap;
self
}
#[must_use]
pub fn extra_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_attributes.insert(key.into(), value.into());
self
}
pub fn build(self) -> PackagerResult<HlsInterstitial> {
let interstitial = HlsInterstitial {
id: self.id,
start_date: self.start_date,
duration: self.duration,
asset_uri: self.asset_uri,
asset_list_uri: self.asset_list_uri,
resume_offset: self.resume_offset,
restrictions: self.restrictions,
snap: self.snap,
extra_attributes: self.extra_attributes,
};
interstitial.validate()?;
Ok(interstitial)
}
}
#[derive(Debug, Clone, Default)]
pub struct InterstitialSchedule {
interstitials: Vec<HlsInterstitial>,
}
impl InterstitialSchedule {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, interstitial: HlsInterstitial) -> PackagerResult<()> {
interstitial.validate()?;
self.interstitials.push(interstitial);
Ok(())
}
#[must_use]
pub fn len(&self) -> usize {
self.interstitials.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.interstitials.is_empty()
}
#[must_use]
pub fn to_m3u8_tags(&self) -> String {
let mut out = String::new();
for item in &self.interstitials {
out.push_str(&item.to_m3u8_tag());
out.push('\n');
}
out
}
#[must_use]
pub fn interstitials(&self) -> &[HlsInterstitial] {
&self.interstitials
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_restriction_skip_str() {
assert_eq!(InterstitialRestriction::Skip.as_str(), "SKIP");
}
#[test]
fn test_restriction_jump_str() {
assert_eq!(InterstitialRestriction::Jump.as_str(), "JUMP");
}
#[test]
fn test_date_range_class_apple_uri() {
assert_eq!(
DateRangeClass::AppleInterstitial.as_uri(),
"com.apple.hls.interstitial"
);
}
#[test]
fn test_date_range_class_scte35_uri() {
assert_eq!(DateRangeClass::Scte35.as_uri(), "com.apple.hls.scte35");
}
#[test]
fn test_date_range_class_custom_uri() {
let cls = DateRangeClass::Custom("urn:example:event".to_string());
assert_eq!(cls.as_uri(), "urn:example:event");
}
#[test]
fn test_date_range_minimal_tag() {
let dr = HlsDateRange::new("dr-1", "2024-01-01T00:00:00Z");
let tag = dr.to_m3u8_tag();
assert!(tag.starts_with("#EXT-X-DATERANGE:"));
assert!(tag.contains("ID=\"dr-1\""));
assert!(tag.contains("START-DATE=\"2024-01-01T00:00:00Z\""));
}
#[test]
fn test_date_range_with_duration() {
let mut dr = HlsDateRange::new("dr-2", "2024-01-01T00:00:00Z");
dr.duration = Some(Duration::from_secs(30));
let tag = dr.to_m3u8_tag();
assert!(tag.contains("DURATION=30"));
}
#[test]
fn test_date_range_with_class() {
let mut dr = HlsDateRange::new("dr-3", "2024-01-01T00:00:00Z");
dr.class = Some(DateRangeClass::AppleInterstitial);
let tag = dr.to_m3u8_tag();
assert!(tag.contains("CLASS=\"com.apple.hls.interstitial\""));
}
#[test]
fn test_date_range_with_end_date() {
let mut dr = HlsDateRange::new("dr-4", "2024-01-01T00:00:00Z");
dr.end_date = Some("2024-01-01T00:00:30Z".to_string());
let tag = dr.to_m3u8_tag();
assert!(tag.contains("END-DATE=\"2024-01-01T00:00:30Z\""));
}
#[test]
fn test_date_range_end_on_next() {
let mut dr = HlsDateRange::new("dr-5", "2024-01-01T00:00:00Z");
dr.end_on_next = true;
let tag = dr.to_m3u8_tag();
assert!(tag.contains("END-ON-NEXT=YES"));
}
#[test]
fn test_date_range_client_attribute() {
let mut dr = HlsDateRange::new("dr-6", "2024-01-01T00:00:00Z");
dr.set_client_attribute("X-FOO", "bar");
let tag = dr.to_m3u8_tag();
assert!(tag.contains("X-FOO=\"bar\""));
}
#[test]
fn test_interstitial_build_minimal() {
let result = HlsInterstitial::builder("ad-1")
.start_date("2024-01-01T00:00:00Z")
.asset_uri("https://ads.example.com/ad.m3u8")
.build();
assert!(result.is_ok());
}
#[test]
fn test_interstitial_build_missing_id_fails() {
let result = HlsInterstitial::builder("")
.start_date("2024-01-01T00:00:00Z")
.asset_uri("https://ads.example.com/ad.m3u8")
.build();
assert!(result.is_err());
}
#[test]
fn test_interstitial_build_missing_asset_fails() {
let result = HlsInterstitial::builder("ad-2")
.start_date("2024-01-01T00:00:00Z")
.build();
assert!(result.is_err());
}
#[test]
fn test_interstitial_asset_list_uri_accepted() {
let result = HlsInterstitial::builder("ad-3")
.start_date("2024-01-01T00:00:00Z")
.asset_list_uri("https://ads.example.com/list.json")
.build();
assert!(result.is_ok());
}
#[test]
fn test_interstitial_tag_contains_ext_x_daterange() {
let ad = HlsInterstitial::builder("preroll")
.start_date("2024-01-01T00:00:00Z")
.duration(Duration::from_secs(30))
.asset_uri("https://ads.example.com/preroll.m3u8")
.build()
.expect("build should succeed");
let tag = ad.to_m3u8_tag();
assert!(tag.contains("EXT-X-DATERANGE"));
assert!(tag.contains("X-ASSET-URI"));
assert!(tag.contains("preroll.m3u8"));
assert!(tag.contains("DURATION=30"));
assert!(tag.contains("com.apple.hls.interstitial"));
}
#[test]
fn test_interstitial_tag_resume_offset() {
let ad = HlsInterstitial::builder("midroll")
.start_date("2024-01-01T00:01:30Z")
.asset_uri("https://ads.example.com/mid.m3u8")
.resume_offset(Duration::ZERO)
.build()
.expect("build should succeed");
let tag = ad.to_m3u8_tag();
assert!(tag.contains("X-RESUME-OFFSET"));
}
#[test]
fn test_interstitial_tag_restrictions() {
let ad = HlsInterstitial::builder("unskippable")
.start_date("2024-01-01T00:00:00Z")
.asset_uri("https://ads.example.com/ad.m3u8")
.restrict(&[InterstitialRestriction::Skip, InterstitialRestriction::Jump])
.build()
.expect("build should succeed");
let tag = ad.to_m3u8_tag();
assert!(tag.contains("X-RESTRICT"));
assert!(tag.contains("SKIP"));
assert!(tag.contains("JUMP"));
}
#[test]
fn test_interstitial_tag_snap() {
let ad = HlsInterstitial::builder("snap-ad")
.start_date("2024-01-01T00:00:00Z")
.asset_uri("https://ads.example.com/ad.m3u8")
.snap(true)
.build()
.expect("build should succeed");
let tag = ad.to_m3u8_tag();
assert!(tag.contains("X-SNAP"));
}
#[test]
fn test_interstitial_extra_attribute() {
let ad = HlsInterstitial::builder("custom-ad")
.start_date("2024-01-01T00:00:00Z")
.asset_uri("https://ads.example.com/ad.m3u8")
.extra_attribute("X-AD-CAMPAIGN", "summer2024")
.build()
.expect("build should succeed");
let tag = ad.to_m3u8_tag();
assert!(tag.contains("X-AD-CAMPAIGN=\"summer2024\""));
}
#[test]
fn test_schedule_empty() {
let schedule = InterstitialSchedule::new();
assert!(schedule.is_empty());
assert_eq!(schedule.len(), 0);
assert_eq!(schedule.to_m3u8_tags(), "");
}
#[test]
fn test_schedule_add_and_render() {
let mut schedule = InterstitialSchedule::new();
let ad1 = HlsInterstitial::builder("pre")
.start_date("2024-01-01T00:00:00Z")
.duration(Duration::from_secs(15))
.asset_uri("https://ads.example.com/pre.m3u8")
.build()
.expect("should succeed");
let ad2 = HlsInterstitial::builder("mid")
.start_date("2024-01-01T00:05:00Z")
.duration(Duration::from_secs(30))
.asset_uri("https://ads.example.com/mid.m3u8")
.build()
.expect("should succeed");
schedule.add(ad1).expect("add should succeed");
schedule.add(ad2).expect("add should succeed");
assert_eq!(schedule.len(), 2);
let tags = schedule.to_m3u8_tags();
assert_eq!(tags.lines().count(), 2);
assert!(tags.contains("pre"));
assert!(tags.contains("mid"));
}
#[test]
fn test_schedule_add_invalid_interstitial_fails() {
let mut schedule = InterstitialSchedule::new();
let bad = HlsInterstitial {
id: "bad".to_string(),
start_date: "2024-01-01T00:00:00Z".to_string(),
duration: None,
asset_uri: None,
asset_list_uri: None,
resume_offset: None,
restrictions: Vec::new(),
snap: false,
extra_attributes: BTreeMap::new(),
};
assert!(schedule.add(bad).is_err());
assert!(schedule.is_empty());
}
#[test]
fn test_schedule_interstitials_accessor() {
let mut schedule = InterstitialSchedule::new();
let ad = HlsInterstitial::builder("ad")
.start_date("2024-01-01T00:00:00Z")
.asset_uri("https://ads.example.com/ad.m3u8")
.build()
.expect("should succeed");
schedule.add(ad).expect("add should succeed");
assert_eq!(schedule.interstitials().len(), 1);
assert_eq!(schedule.interstitials()[0].id, "ad");
}
#[test]
fn test_schedule_tags_end_with_newlines() {
let mut schedule = InterstitialSchedule::new();
let ad = HlsInterstitial::builder("nl-test")
.start_date("2024-01-01T00:00:00Z")
.asset_uri("https://ads.example.com/ad.m3u8")
.build()
.expect("should succeed");
schedule.add(ad).expect("add should succeed");
let tags = schedule.to_m3u8_tags();
assert!(tags.ends_with('\n'));
}
}