1use crate::decision::{AdBreakDecision, AssetSource, RestrictMode, SnapMode};
23use crate::error::{Error, Result};
24use alloc::format;
25use alloc::string::{String, ToString};
26use alloc::vec::Vec;
27use broadcast_hls::{AttrValue, MediaPlaylist, parse_attribute_list_strict, render_attribute_list};
28
29pub const INTERSTITIAL_CLASS: &str = "com.apple.hls.interstitial";
31
32const TAG: &str = "#EXT-X-DATERANGE:";
33
34#[derive(Debug, Clone, PartialEq)]
38pub struct InterstitialDateRange {
39 pub id: String,
41 pub start_date: String,
44 pub duration: Option<f64>,
46 pub asset: AssetSource,
48 pub resume_offset: Option<f64>,
50 pub playout_limit: Option<f64>,
52 pub snap: Vec<SnapMode>,
54 pub restrict: Vec<RestrictMode>,
56}
57
58impl InterstitialDateRange {
59 pub fn from_decision(
62 decision: &AdBreakDecision,
63 start_date: impl Into<String>,
64 duration: Option<f64>,
65 ) -> Self {
66 InterstitialDateRange {
67 id: decision.id.clone(),
68 start_date: start_date.into(),
69 duration,
70 asset: decision.asset.clone(),
71 resume_offset: decision.resume_offset,
72 playout_limit: decision.playout_limit,
73 snap: decision.snap.clone(),
74 restrict: decision.restrict.clone(),
75 }
76 }
77
78 pub fn to_tag_line(&self) -> Result<String> {
97 let mut attrs = alloc::vec![
98 (String::from("ID"), AttrValue::quoted(self.id.clone())?),
99 (
100 String::from("CLASS"),
101 AttrValue::quoted(INTERSTITIAL_CLASS)?,
102 ),
103 (
104 String::from("START-DATE"),
105 AttrValue::quoted(self.start_date.clone())?,
106 ),
107 ];
108 if let Some(d) = self.duration {
109 attrs.push((
110 String::from("DURATION"),
111 AttrValue::bare(fmt_checked_secs("DURATION", d)?)?,
112 ));
113 }
114 match &self.asset {
115 AssetSource::Uri(uri) => {
116 attrs.push((String::from("X-ASSET-URI"), AttrValue::quoted(uri.clone())?));
117 }
118 AssetSource::List(uri) => {
119 attrs.push((
120 String::from("X-ASSET-LIST"),
121 AttrValue::quoted(uri.clone())?,
122 ));
123 }
124 }
125 if let Some(v) = self.resume_offset {
126 attrs.push((
127 String::from("X-RESUME-OFFSET"),
128 AttrValue::bare(fmt_checked_secs("X-RESUME-OFFSET", v)?)?,
129 ));
130 }
131 if let Some(v) = self.playout_limit {
132 attrs.push((
133 String::from("X-PLAYOUT-LIMIT"),
134 AttrValue::bare(fmt_checked_secs("X-PLAYOUT-LIMIT", v)?)?,
135 ));
136 }
137 if !self.snap.is_empty() {
138 let list = self
139 .snap
140 .iter()
141 .map(SnapMode::name)
142 .collect::<Vec<_>>()
143 .join(",");
144 attrs.push((String::from("X-SNAP"), AttrValue::quoted(list)?));
145 }
146 if !self.restrict.is_empty() {
147 let list = self
148 .restrict
149 .iter()
150 .map(RestrictMode::name)
151 .collect::<Vec<_>>()
152 .join(",");
153 attrs.push((String::from("X-RESTRICT"), AttrValue::quoted(list)?));
154 }
155 let mut body = String::new();
160 render_attribute_list(&mut body, &attrs);
161 let mut out = String::from(TAG);
162 out.push_str(body.trim_start_matches(','));
163 Ok(out)
164 }
165
166 pub fn parse_tag_line(s: &str) -> Result<Self> {
172 let body = s
173 .strip_prefix(TAG)
174 .ok_or_else(|| Error::TagParse("missing #EXT-X-DATERANGE: prefix".to_string()))?;
175
176 let (attrs, _quoted) = parse_attribute_list_strict(body)?;
179
180 let id = attrs.get("ID").cloned();
181 let class_ok = attrs.get("CLASS").map(String::as_str) == Some(INTERSTITIAL_CLASS);
182 let start_date = attrs.get("START-DATE").cloned();
183 let duration = attrs
184 .get("DURATION")
185 .map(|v| parse_checked_secs("DURATION", v))
186 .transpose()?;
187 let uri = attrs.get("X-ASSET-URI").cloned();
188 let list = attrs.get("X-ASSET-LIST").cloned();
189 let resume_offset = attrs
190 .get("X-RESUME-OFFSET")
191 .map(|v| parse_checked_secs("X-RESUME-OFFSET", v))
192 .transpose()?;
193 let playout_limit = attrs
194 .get("X-PLAYOUT-LIMIT")
195 .map(|v| parse_checked_secs("X-PLAYOUT-LIMIT", v))
196 .transpose()?;
197 let snap = attrs
198 .get("X-SNAP")
199 .map(|v| parse_snap_list(v))
200 .unwrap_or_default();
201 let restrict = attrs
202 .get("X-RESTRICT")
203 .map(|v| parse_restrict_list(v))
204 .unwrap_or_default();
205
206 if !class_ok {
207 return Err(Error::TagParse(
208 "not an Interstitial EXT-X-DATERANGE (CLASS missing or mismatched)".to_string(),
209 ));
210 }
211 let asset = match (uri, list) {
212 (Some(u), None) => AssetSource::Uri(u),
213 (None, Some(l)) => AssetSource::List(l),
214 _ => return Err(Error::InvalidAssetSource),
215 };
216
217 Ok(InterstitialDateRange {
218 id: id.ok_or_else(|| Error::TagParse("missing ID".to_string()))?,
219 start_date: start_date
220 .ok_or_else(|| Error::TagParse("missing START-DATE".to_string()))?,
221 duration,
222 asset,
223 resume_offset,
224 playout_limit,
225 snap,
226 restrict,
227 })
228 }
229}
230
231const PDT_TAG: &str = "#EXT-X-PROGRAM-DATE-TIME:";
234
235fn has_program_date_time(base: &MediaPlaylist) -> bool {
239 base.extra_tags.iter().any(|t| t.starts_with(PDT_TAG))
240 || base.segments.iter().any(|s| {
241 s.program_date_time.is_some() || s.pre_tags.iter().any(|t| t.starts_with(PDT_TAG))
242 })
243}
244
245pub fn render_session_playlist(
260 base: &MediaPlaylist,
261 active: Option<&InterstitialDateRange>,
262) -> Result<MediaPlaylist> {
263 let mut out = base.clone();
264 if let Some(dr) = active {
265 if !has_program_date_time(base) {
266 return Err(Error::MissingProgramDateTime);
267 }
268 out.extra_tags.push(dr.to_tag_line()?);
269 }
270 Ok(out)
271}
272
273#[derive(Debug, Clone)]
285pub struct SessionPlaylistBase {
286 head: String,
287 tail: String,
288 has_pdt: bool,
289}
290
291impl SessionPlaylistBase {
292 pub fn new(base: &MediaPlaylist) -> Result<Self> {
294 let text = base.to_m3u8()?;
295 let first_group = ["\n#EXT-X-PART:", "\n#EXTINF:"]
297 .iter()
298 .filter_map(|tag| text.find(tag))
299 .min();
300 let at = first_group
301 .or_else(|| text.rfind("\n#EXT-X-ENDLIST"))
302 .map_or(text.len(), |i| i + 1);
303 let (head, tail) = text.split_at(at);
304 Ok(SessionPlaylistBase {
305 head: head.to_string(),
306 tail: tail.to_string(),
307 has_pdt: has_program_date_time(base),
308 })
309 }
310
311 pub fn render_plain(&self) -> String {
313 let mut out = String::with_capacity(self.head.len() + self.tail.len());
314 out.push_str(&self.head);
315 out.push_str(&self.tail);
316 out
317 }
318
319 pub fn render_into(
323 &self,
324 out: &mut String,
325 active: Option<&InterstitialDateRange>,
326 ) -> Result<()> {
327 let line = match active {
328 Some(dr) => {
329 if !self.has_pdt {
330 return Err(Error::MissingProgramDateTime);
331 }
332 Some(dr.to_tag_line()?)
333 }
334 None => None,
335 };
336 out.push_str(&self.head);
337 if let Some(line) = line {
338 out.push_str(&line);
339 out.push('\n');
340 }
341 out.push_str(&self.tail);
342 Ok(())
343 }
344}
345
346fn fmt_checked_secs(what: &'static str, v: f64) -> Result<String> {
355 if !v.is_finite() || v < 0.0 {
356 return Err(Error::InvalidDuration { what, value: v });
357 }
358 let trunc = v as i64;
359 Ok(if v == trunc as f64 {
360 format!("{trunc}")
361 } else {
362 format!("{v}")
363 })
364}
365
366fn parse_checked_secs(what: &'static str, v: &str) -> Result<f64> {
371 let parsed: f64 = v
372 .parse()
373 .map_err(|_| Error::TagParse(format!("bad number: {v}")))?;
374 if !parsed.is_finite() || parsed < 0.0 {
375 return Err(Error::InvalidDuration {
376 what,
377 value: parsed,
378 });
379 }
380 Ok(parsed)
381}
382
383fn parse_snap_list(v: &str) -> Vec<SnapMode> {
384 v.split(',')
385 .filter_map(|tok| match tok.trim() {
386 "OUT" => Some(SnapMode::Out),
387 "IN" => Some(SnapMode::In),
388 _ => None,
389 })
390 .collect()
391}
392
393fn parse_restrict_list(v: &str) -> Vec<RestrictMode> {
394 v.split(',')
395 .filter_map(|tok| match tok.trim() {
396 "SKIP" => Some(RestrictMode::Skip),
397 "JUMP" => Some(RestrictMode::Jump),
398 _ => None,
399 })
400 .collect()
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use alloc::vec;
407 use broadcast_hls::{DecimalSeconds, MediaSegment};
408
409 fn sample() -> InterstitialDateRange {
410 InterstitialDateRange {
411 id: "ad1".to_string(),
412 start_date: "2020-01-02T21:55:44.000Z".to_string(),
413 duration: Some(15.0),
414 asset: AssetSource::Uri("http://example.com/ad1.m3u8".to_string()),
415 resume_offset: Some(0.0),
416 playout_limit: None,
417 snap: Vec::new(),
418 restrict: vec![RestrictMode::Skip, RestrictMode::Jump],
419 }
420 }
421
422 #[test]
423 fn tag_line_matches_the_spec_example_shape() {
424 let line = sample().to_tag_line().unwrap();
426 assert!(line.starts_with(TAG));
427 assert!(line.contains(r#"CLASS="com.apple.hls.interstitial""#));
428 assert!(line.contains(r#"X-ASSET-URI="http://example.com/ad1.m3u8""#));
429 assert!(line.contains("X-RESUME-OFFSET=0"));
430 assert!(line.contains(r#"X-RESTRICT="SKIP,JUMP""#));
431 }
432
433 #[test]
434 fn tag_line_round_trips() {
435 let dr = sample();
436 let line = dr.to_tag_line().unwrap();
437 let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
438 assert_eq!(back, dr);
439 }
440
441 #[test]
442 fn asset_list_variant_round_trips() {
443 let dr = InterstitialDateRange {
444 id: "ad2".to_string(),
445 start_date: "2020-01-02T21:55:44.000Z".to_string(),
446 duration: Some(30.0),
447 asset: AssetSource::List("http://example.com/adv.json".to_string()),
448 resume_offset: None,
449 playout_limit: Some(20.0),
450 snap: vec![SnapMode::Out, SnapMode::In],
451 restrict: Vec::new(),
452 };
453 let line = dr.to_tag_line().unwrap();
454 assert!(line.contains(r#"X-ASSET-LIST="http://example.com/adv.json""#));
455 assert!(line.contains(r#"X-SNAP="OUT,IN""#));
456 let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
457 assert_eq!(back, dr);
458 }
459
460 #[test]
464 fn mutating_a_field_changes_the_output() {
465 let base = sample();
466 let base_line = base.to_tag_line().unwrap();
467
468 let mut mutated = base.clone();
469 mutated.id = "different-id".to_string();
470 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
471
472 let mut mutated = base.clone();
473 mutated.resume_offset = Some(5.0);
474 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
475
476 let mut mutated = base.clone();
477 mutated.restrict = vec![RestrictMode::Skip];
478 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
479
480 let mut mutated = base;
481 mutated.asset = AssetSource::Uri("http://example.com/different.m3u8".to_string());
482 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
483 }
484
485 #[test]
489 fn to_tag_line_rejects_injection_in_asset_uri() {
490 let mut dr = sample();
491 dr.asset = AssetSource::Uri("http://example.com/ad1.m3u8\"\r\n#EXT-X-ENDLIST".to_string());
492 assert!(matches!(
493 dr.to_tag_line().unwrap_err(),
494 Error::HlsAttrValue(_)
495 ));
496 }
497
498 #[test]
499 fn to_tag_line_rejects_injection_in_id() {
500 let mut dr = sample();
501 dr.id = "ad\"1".to_string();
502 assert!(matches!(
503 dr.to_tag_line().unwrap_err(),
504 Error::HlsAttrValue(_)
505 ));
506 }
507
508 #[test]
511 fn to_tag_line_rejects_non_finite_and_negative_durations() {
512 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
513 let mut dr = sample();
514 dr.duration = Some(bad);
515 let err = dr.to_tag_line().unwrap_err();
516 assert!(
517 matches!(err, Error::InvalidDuration { .. }),
518 "duration {bad} should be rejected, got {err:?}"
519 );
520
521 let mut dr = sample();
522 dr.resume_offset = Some(bad);
523 assert!(matches!(
524 dr.to_tag_line().unwrap_err(),
525 Error::InvalidDuration { .. }
526 ));
527
528 let mut dr = sample();
529 dr.playout_limit = Some(bad);
530 assert!(matches!(
531 dr.to_tag_line().unwrap_err(),
532 Error::InvalidDuration { .. }
533 ));
534 }
535 }
536
537 #[test]
538 fn parse_rejects_non_finite_duration() {
539 let line = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
540 START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\",\
541 DURATION=nan";
542 assert!(matches!(
543 InterstitialDateRange::parse_tag_line(line).unwrap_err(),
544 Error::InvalidDuration { .. }
545 ));
546 }
547
548 #[test]
549 fn parse_rejects_wrong_or_missing_class() {
550 let line = "#EXT-X-DATERANGE:ID=\"x\",START-DATE=\"2020-01-01T00:00:00Z\",\
551 X-ASSET-URI=\"http://x/a.m3u8\"";
552 let err = InterstitialDateRange::parse_tag_line(line).unwrap_err();
553 assert!(matches!(err, Error::TagParse(_)));
554 }
555
556 #[test]
557 fn parse_rejects_both_or_neither_asset_source() {
558 let both = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
559 START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\",\
560 X-ASSET-LIST=\"http://x/list.json\"";
561 assert!(matches!(
562 InterstitialDateRange::parse_tag_line(both).unwrap_err(),
563 Error::InvalidAssetSource
564 ));
565
566 let neither = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
567 START-DATE=\"2020-01-01T00:00:00Z\"";
568 assert!(matches!(
569 InterstitialDateRange::parse_tag_line(neither).unwrap_err(),
570 Error::InvalidAssetSource
571 ));
572 }
573
574 #[test]
575 fn render_session_playlist_appends_only_for_the_active_session() {
576 let mut base = MediaPlaylist {
577 target_duration: 6,
578 ..Default::default()
579 };
580 base.segments.push(MediaSegment {
581 duration: DecimalSeconds::new(6.0).unwrap(),
582 uri: "main.ts".to_string(),
583 ..Default::default()
584 });
585
586 base.segments[0].pre_tags = vec![PDT_LINE.to_string()];
587
588 let dr = sample();
589 let with_break = render_session_playlist(&base, Some(&dr)).unwrap();
590 let without_break = render_session_playlist(&base, None).unwrap();
591
592 assert!(with_break.to_m3u8().unwrap().contains("X-ASSET-URI"));
593 assert!(!without_break.to_m3u8().unwrap().contains("X-ASSET-URI"));
594 assert!(base.extra_tags.is_empty());
597 assert_eq!(with_break.segments, without_break.segments);
599 }
600
601 const PDT_LINE: &str = "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z";
602
603 fn base_with_segments(n: usize, pdt: bool) -> MediaPlaylist {
604 let mut base = MediaPlaylist {
605 target_duration: 6,
606 ..Default::default()
607 };
608 for i in 0..n {
609 base.segments.push(MediaSegment {
610 duration: DecimalSeconds::new(6.0).unwrap(),
611 uri: format!("main{i}.ts"),
612 pre_tags: if pdt && i == 0 {
613 vec![PDT_LINE.to_string()]
614 } else {
615 Vec::new()
616 },
617 ..Default::default()
618 });
619 }
620 base
621 }
622
623 #[test]
627 fn daterange_without_pdt_is_an_error_on_both_paths() {
628 let dr = sample();
629 let no_pdt = base_with_segments(2, false);
630 assert!(matches!(
631 render_session_playlist(&no_pdt, Some(&dr)).unwrap_err(),
632 Error::MissingProgramDateTime
633 ));
634 let b = SessionPlaylistBase::new(&no_pdt).unwrap();
635 let mut out = String::new();
636 assert!(matches!(
637 b.render_into(&mut out, Some(&dr)).unwrap_err(),
638 Error::MissingProgramDateTime
639 ));
640 assert!(out.is_empty(), "nothing appended on error");
641 assert!(render_session_playlist(&no_pdt, None).is_ok());
643 b.render_into(&mut out, None).unwrap();
644 assert_eq!(out, no_pdt.to_m3u8().unwrap());
645
646 let mut extra = base_with_segments(1, false);
648 extra.extra_tags.push(PDT_LINE.to_string());
649 assert!(render_session_playlist(&extra, Some(&dr)).is_ok());
650 assert!(
651 SessionPlaylistBase::new(&extra)
652 .unwrap()
653 .render_into(&mut String::new(), Some(&dr))
654 .is_ok()
655 );
656 }
657
658 #[test]
661 fn session_playlist_base_splices_one_line_before_the_first_extinf() {
662 let base = base_with_segments(3, true);
663 let dr = sample();
664 let plain = base.to_m3u8().unwrap();
665 let b = SessionPlaylistBase::new(&base).unwrap();
666 assert_eq!(b.render_plain(), plain);
667
668 let mut out = String::new();
669 b.render_into(&mut out, Some(&dr)).unwrap();
670 let line = dr.to_tag_line().unwrap();
671 let expected = plain.replacen("#EXTINF:", &format!("{line}\n#EXTINF:"), 1);
672 assert_eq!(out, expected);
673 assert_eq!(out.lines().filter(|l| l.starts_with(TAG)).count(), 1);
674 let reparsed = MediaPlaylist::parse(&out).unwrap();
675 assert_eq!(reparsed.segments.len(), 3);
676
677 let mut bad = sample();
679 bad.asset = AssetSource::Uri("x\"\n#EXT-X-ENDLIST".to_string());
680 let mut out2 = String::from("keep");
681 assert!(b.render_into(&mut out2, Some(&bad)).is_err());
682 assert_eq!(out2, "keep");
683 }
684
685 #[test]
687 fn session_playlist_base_without_segments_inserts_before_endlist() {
688 let mut base = MediaPlaylist {
689 target_duration: 6,
690 endlist: true,
691 ..Default::default()
692 };
693 base.extra_tags.push(PDT_LINE.to_string());
694 let b = SessionPlaylistBase::new(&base).unwrap();
695 let mut out = String::new();
696 b.render_into(&mut out, Some(&sample())).unwrap();
697 let lines: Vec<&str> = out.lines().collect();
698 let n = lines.len();
699 assert_eq!(lines[n - 1], "#EXT-X-ENDLIST");
700 assert!(lines[n - 2].starts_with(TAG));
701 }
702
703 #[test]
707 fn low_latency_base_gets_the_tag_before_the_first_part() {
708 use broadcast_hls::{LowLatencyConfig, PartSpec};
709 let mut base = base_with_segments(2, true);
710 for seg in &mut base.segments {
711 seg.parts = vec![
712 PartSpec {
713 uri: format!("{}.0.m4s", seg.uri),
714 duration: DecimalSeconds::new(3.0).unwrap(),
715 ..Default::default()
716 },
717 PartSpec {
718 uri: format!("{}.1.m4s", seg.uri),
719 duration: DecimalSeconds::new(3.0).unwrap(),
720 ..Default::default()
721 },
722 ];
723 }
724 base.low_latency = Some(LowLatencyConfig {
725 part_target: Some(DecimalSeconds::new(3.0).unwrap()),
726 ..Default::default()
727 });
728 let plain = base.to_m3u8().unwrap();
729 let first_part = plain.find("#EXT-X-PART:").expect("the base has parts");
730 let first_extinf = plain.find("#EXTINF:").unwrap();
731 assert!(
732 first_part < first_extinf,
733 "premise: parts precede EXTINF\n{plain}"
734 );
735
736 let mut out = String::new();
737 SessionPlaylistBase::new(&base)
738 .unwrap()
739 .render_into(&mut out, Some(&sample()))
740 .unwrap();
741 let tag = out.find(TAG).expect("tag present");
742 let part = out.find("#EXT-X-PART:").unwrap();
743 let extinf = out.find("#EXTINF:").unwrap();
744 assert!(tag < part && part < extinf, "{out}");
745 assert_eq!(out.matches(TAG).count(), 1);
746 assert!(out.find("#EXT-X-PART-INF").is_none_or(|i| i < tag), "{out}");
748 MediaPlaylist::parse(&out).expect("the spliced LL playlist parses");
749 }
750
751 #[test]
752 fn parse_tag_line_rejects_malformed_attribute_list_unterminated_quote() {
753 let line = "#EXT-X-DATERANGE:ID=\"x";
754 assert!(matches!(
755 InterstitialDateRange::parse_tag_line(line).unwrap_err(),
756 Error::HlsAttrValue(broadcast_hls::Error::UnterminatedQuotedString { .. })
757 ));
758 }
759
760 #[test]
761 fn parse_tag_line_rejects_malformed_attribute_list_duplicate_key() {
762 let line = "#EXT-X-DATERANGE:ID=\"x\",ID=\"y\",CLASS=\"com.apple.hls.interstitial\",START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\"";
763 assert!(matches!(
764 InterstitialDateRange::parse_tag_line(line).unwrap_err(),
765 Error::HlsAttrValue(broadcast_hls::Error::DuplicateAttribute { name }) if name == "ID"
766 ));
767 }
768}