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, 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(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
241 .segments
242 .iter()
243 .any(|s| s.pre_tags.iter().any(|t| t.starts_with(PDT_TAG)))
244}
245
246pub fn render_session_playlist(
261 base: &MediaPlaylist,
262 active: Option<&InterstitialDateRange>,
263) -> Result<MediaPlaylist> {
264 let mut out = base.clone();
265 if let Some(dr) = active {
266 if !has_program_date_time(base) {
267 return Err(Error::MissingProgramDateTime);
268 }
269 out.extra_tags.push(dr.to_tag_line()?);
270 }
271 Ok(out)
272}
273
274#[derive(Debug, Clone)]
286pub struct SessionPlaylistBase {
287 head: String,
288 tail: String,
289 has_pdt: bool,
290}
291
292impl SessionPlaylistBase {
293 pub fn new(base: &MediaPlaylist) -> Result<Self> {
295 let text = base.to_m3u8()?;
296 let first_group = ["\n#EXT-X-PART:", "\n#EXTINF:"]
298 .iter()
299 .filter_map(|tag| text.find(tag))
300 .min();
301 let at = first_group
302 .or_else(|| text.rfind("\n#EXT-X-ENDLIST"))
303 .map_or(text.len(), |i| i + 1);
304 let (head, tail) = text.split_at(at);
305 Ok(SessionPlaylistBase {
306 head: head.to_string(),
307 tail: tail.to_string(),
308 has_pdt: has_program_date_time(base),
309 })
310 }
311
312 pub fn render_plain(&self) -> String {
314 let mut out = String::with_capacity(self.head.len() + self.tail.len());
315 out.push_str(&self.head);
316 out.push_str(&self.tail);
317 out
318 }
319
320 pub fn render_into(
324 &self,
325 out: &mut String,
326 active: Option<&InterstitialDateRange>,
327 ) -> Result<()> {
328 let line = match active {
329 Some(dr) => {
330 if !self.has_pdt {
331 return Err(Error::MissingProgramDateTime);
332 }
333 Some(dr.to_tag_line()?)
334 }
335 None => None,
336 };
337 out.push_str(&self.head);
338 if let Some(line) = line {
339 out.push_str(&line);
340 out.push('\n');
341 }
342 out.push_str(&self.tail);
343 Ok(())
344 }
345}
346
347fn fmt_checked_secs(what: &'static str, v: f64) -> Result<String> {
356 if !v.is_finite() || v < 0.0 {
357 return Err(Error::InvalidDuration { what, value: v });
358 }
359 let trunc = v as i64;
360 Ok(if v == trunc as f64 {
361 format!("{trunc}")
362 } else {
363 format!("{v}")
364 })
365}
366
367fn parse_checked_secs(what: &'static str, v: &str) -> Result<f64> {
372 let parsed: f64 = v
373 .parse()
374 .map_err(|_| Error::TagParse(format!("bad number: {v}")))?;
375 if !parsed.is_finite() || parsed < 0.0 {
376 return Err(Error::InvalidDuration {
377 what,
378 value: parsed,
379 });
380 }
381 Ok(parsed)
382}
383
384fn parse_snap_list(v: &str) -> Vec<SnapMode> {
385 v.split(',')
386 .filter_map(|tok| match tok.trim() {
387 "OUT" => Some(SnapMode::Out),
388 "IN" => Some(SnapMode::In),
389 _ => None,
390 })
391 .collect()
392}
393
394fn parse_restrict_list(v: &str) -> Vec<RestrictMode> {
395 v.split(',')
396 .filter_map(|tok| match tok.trim() {
397 "SKIP" => Some(RestrictMode::Skip),
398 "JUMP" => Some(RestrictMode::Jump),
399 _ => None,
400 })
401 .collect()
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use alloc::vec;
408 use broadcast_hls::{DecimalSeconds, MediaSegment};
409
410 fn sample() -> InterstitialDateRange {
411 InterstitialDateRange {
412 id: "ad1".to_string(),
413 start_date: "2020-01-02T21:55:44.000Z".to_string(),
414 duration: Some(15.0),
415 asset: AssetSource::Uri("http://example.com/ad1.m3u8".to_string()),
416 resume_offset: Some(0.0),
417 playout_limit: None,
418 snap: Vec::new(),
419 restrict: vec![RestrictMode::Skip, RestrictMode::Jump],
420 }
421 }
422
423 #[test]
424 fn tag_line_matches_the_spec_example_shape() {
425 let line = sample().to_tag_line().unwrap();
427 assert!(line.starts_with(TAG));
428 assert!(line.contains(r#"CLASS="com.apple.hls.interstitial""#));
429 assert!(line.contains(r#"X-ASSET-URI="http://example.com/ad1.m3u8""#));
430 assert!(line.contains("X-RESUME-OFFSET=0"));
431 assert!(line.contains(r#"X-RESTRICT="SKIP,JUMP""#));
432 }
433
434 #[test]
435 fn tag_line_round_trips() {
436 let dr = sample();
437 let line = dr.to_tag_line().unwrap();
438 let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
439 assert_eq!(back, dr);
440 }
441
442 #[test]
443 fn asset_list_variant_round_trips() {
444 let dr = InterstitialDateRange {
445 id: "ad2".to_string(),
446 start_date: "2020-01-02T21:55:44.000Z".to_string(),
447 duration: Some(30.0),
448 asset: AssetSource::List("http://example.com/adv.json".to_string()),
449 resume_offset: None,
450 playout_limit: Some(20.0),
451 snap: vec![SnapMode::Out, SnapMode::In],
452 restrict: Vec::new(),
453 };
454 let line = dr.to_tag_line().unwrap();
455 assert!(line.contains(r#"X-ASSET-LIST="http://example.com/adv.json""#));
456 assert!(line.contains(r#"X-SNAP="OUT,IN""#));
457 let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
458 assert_eq!(back, dr);
459 }
460
461 #[test]
465 fn mutating_a_field_changes_the_output() {
466 let base = sample();
467 let base_line = base.to_tag_line().unwrap();
468
469 let mut mutated = base.clone();
470 mutated.id = "different-id".to_string();
471 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
472
473 let mut mutated = base.clone();
474 mutated.resume_offset = Some(5.0);
475 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
476
477 let mut mutated = base.clone();
478 mutated.restrict = vec![RestrictMode::Skip];
479 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
480
481 let mut mutated = base;
482 mutated.asset = AssetSource::Uri("http://example.com/different.m3u8".to_string());
483 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
484 }
485
486 #[test]
490 fn to_tag_line_rejects_injection_in_asset_uri() {
491 let mut dr = sample();
492 dr.asset = AssetSource::Uri("http://example.com/ad1.m3u8\"\r\n#EXT-X-ENDLIST".to_string());
493 assert!(matches!(
494 dr.to_tag_line().unwrap_err(),
495 Error::HlsAttrValue(_)
496 ));
497 }
498
499 #[test]
500 fn to_tag_line_rejects_injection_in_id() {
501 let mut dr = sample();
502 dr.id = "ad\"1".to_string();
503 assert!(matches!(
504 dr.to_tag_line().unwrap_err(),
505 Error::HlsAttrValue(_)
506 ));
507 }
508
509 #[test]
512 fn to_tag_line_rejects_non_finite_and_negative_durations() {
513 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
514 let mut dr = sample();
515 dr.duration = Some(bad);
516 let err = dr.to_tag_line().unwrap_err();
517 assert!(
518 matches!(err, Error::InvalidDuration { .. }),
519 "duration {bad} should be rejected, got {err:?}"
520 );
521
522 let mut dr = sample();
523 dr.resume_offset = Some(bad);
524 assert!(matches!(
525 dr.to_tag_line().unwrap_err(),
526 Error::InvalidDuration { .. }
527 ));
528
529 let mut dr = sample();
530 dr.playout_limit = Some(bad);
531 assert!(matches!(
532 dr.to_tag_line().unwrap_err(),
533 Error::InvalidDuration { .. }
534 ));
535 }
536 }
537
538 #[test]
539 fn parse_rejects_non_finite_duration() {
540 let line = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
541 START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\",\
542 DURATION=nan";
543 assert!(matches!(
544 InterstitialDateRange::parse_tag_line(line).unwrap_err(),
545 Error::InvalidDuration { .. }
546 ));
547 }
548
549 #[test]
550 fn parse_rejects_wrong_or_missing_class() {
551 let line = "#EXT-X-DATERANGE:ID=\"x\",START-DATE=\"2020-01-01T00:00:00Z\",\
552 X-ASSET-URI=\"http://x/a.m3u8\"";
553 let err = InterstitialDateRange::parse_tag_line(line).unwrap_err();
554 assert!(matches!(err, Error::TagParse(_)));
555 }
556
557 #[test]
558 fn parse_rejects_both_or_neither_asset_source() {
559 let both = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
560 START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\",\
561 X-ASSET-LIST=\"http://x/list.json\"";
562 assert!(matches!(
563 InterstitialDateRange::parse_tag_line(both).unwrap_err(),
564 Error::InvalidAssetSource
565 ));
566
567 let neither = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
568 START-DATE=\"2020-01-01T00:00:00Z\"";
569 assert!(matches!(
570 InterstitialDateRange::parse_tag_line(neither).unwrap_err(),
571 Error::InvalidAssetSource
572 ));
573 }
574
575 #[test]
576 fn render_session_playlist_appends_only_for_the_active_session() {
577 let mut base = MediaPlaylist {
578 target_duration: 6,
579 ..Default::default()
580 };
581 base.segments.push(MediaSegment {
582 duration: DecimalSeconds::new(6.0).unwrap(),
583 uri: "main.ts".to_string(),
584 ..Default::default()
585 });
586
587 base.segments[0].pre_tags = vec![PDT_LINE.to_string()];
588
589 let dr = sample();
590 let with_break = render_session_playlist(&base, Some(&dr)).unwrap();
591 let without_break = render_session_playlist(&base, None).unwrap();
592
593 assert!(with_break.to_m3u8().unwrap().contains("X-ASSET-URI"));
594 assert!(!without_break.to_m3u8().unwrap().contains("X-ASSET-URI"));
595 assert!(base.extra_tags.is_empty());
598 assert_eq!(with_break.segments, without_break.segments);
600 }
601
602 const PDT_LINE: &str = "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z";
603
604 fn base_with_segments(n: usize, pdt: bool) -> MediaPlaylist {
605 let mut base = MediaPlaylist {
606 target_duration: 6,
607 ..Default::default()
608 };
609 for i in 0..n {
610 base.segments.push(MediaSegment {
611 duration: DecimalSeconds::new(6.0).unwrap(),
612 uri: format!("main{i}.ts"),
613 pre_tags: if pdt && i == 0 {
614 vec![PDT_LINE.to_string()]
615 } else {
616 Vec::new()
617 },
618 ..Default::default()
619 });
620 }
621 base
622 }
623
624 #[test]
628 fn daterange_without_pdt_is_an_error_on_both_paths() {
629 let dr = sample();
630 let no_pdt = base_with_segments(2, false);
631 assert!(matches!(
632 render_session_playlist(&no_pdt, Some(&dr)).unwrap_err(),
633 Error::MissingProgramDateTime
634 ));
635 let b = SessionPlaylistBase::new(&no_pdt).unwrap();
636 let mut out = String::new();
637 assert!(matches!(
638 b.render_into(&mut out, Some(&dr)).unwrap_err(),
639 Error::MissingProgramDateTime
640 ));
641 assert!(out.is_empty(), "nothing appended on error");
642 assert!(render_session_playlist(&no_pdt, None).is_ok());
644 b.render_into(&mut out, None).unwrap();
645 assert_eq!(out, no_pdt.to_m3u8().unwrap());
646
647 let mut extra = base_with_segments(1, false);
649 extra.extra_tags.push(PDT_LINE.to_string());
650 assert!(render_session_playlist(&extra, Some(&dr)).is_ok());
651 assert!(
652 SessionPlaylistBase::new(&extra)
653 .unwrap()
654 .render_into(&mut String::new(), Some(&dr))
655 .is_ok()
656 );
657 }
658
659 #[test]
662 fn session_playlist_base_splices_one_line_before_the_first_extinf() {
663 let base = base_with_segments(3, true);
664 let dr = sample();
665 let plain = base.to_m3u8().unwrap();
666 let b = SessionPlaylistBase::new(&base).unwrap();
667 assert_eq!(b.render_plain(), plain);
668
669 let mut out = String::new();
670 b.render_into(&mut out, Some(&dr)).unwrap();
671 let line = dr.to_tag_line().unwrap();
672 let expected = plain.replacen("#EXTINF:", &format!("{line}\n#EXTINF:"), 1);
673 assert_eq!(out, expected);
674 assert_eq!(out.lines().filter(|l| l.starts_with(TAG)).count(), 1);
675 let reparsed = MediaPlaylist::parse(&out).unwrap();
676 assert_eq!(reparsed.segments.len(), 3);
677
678 let mut bad = sample();
680 bad.asset = AssetSource::Uri("x\"\n#EXT-X-ENDLIST".to_string());
681 let mut out2 = String::from("keep");
682 assert!(b.render_into(&mut out2, Some(&bad)).is_err());
683 assert_eq!(out2, "keep");
684 }
685
686 #[test]
688 fn session_playlist_base_without_segments_inserts_before_endlist() {
689 let mut base = MediaPlaylist {
690 target_duration: 6,
691 endlist: true,
692 ..Default::default()
693 };
694 base.extra_tags.push(PDT_LINE.to_string());
695 let b = SessionPlaylistBase::new(&base).unwrap();
696 let mut out = String::new();
697 b.render_into(&mut out, Some(&sample())).unwrap();
698 let lines: Vec<&str> = out.lines().collect();
699 let n = lines.len();
700 assert_eq!(lines[n - 1], "#EXT-X-ENDLIST");
701 assert!(lines[n - 2].starts_with(TAG));
702 }
703
704 #[test]
708 fn low_latency_base_gets_the_tag_before_the_first_part() {
709 use broadcast_hls::{LowLatencyConfig, PartSpec};
710 let mut base = base_with_segments(2, true);
711 for seg in &mut base.segments {
712 seg.parts = vec![
713 PartSpec {
714 uri: format!("{}.0.m4s", seg.uri),
715 duration: DecimalSeconds::new(3.0).unwrap(),
716 ..Default::default()
717 },
718 PartSpec {
719 uri: format!("{}.1.m4s", seg.uri),
720 duration: DecimalSeconds::new(3.0).unwrap(),
721 ..Default::default()
722 },
723 ];
724 }
725 base.low_latency = Some(LowLatencyConfig {
726 part_target: Some(DecimalSeconds::new(3.0).unwrap()),
727 ..Default::default()
728 });
729 let plain = base.to_m3u8().unwrap();
730 let first_part = plain.find("#EXT-X-PART:").expect("the base has parts");
731 let first_extinf = plain.find("#EXTINF:").unwrap();
732 assert!(
733 first_part < first_extinf,
734 "premise: parts precede EXTINF\n{plain}"
735 );
736
737 let mut out = String::new();
738 SessionPlaylistBase::new(&base)
739 .unwrap()
740 .render_into(&mut out, Some(&sample()))
741 .unwrap();
742 let tag = out.find(TAG).expect("tag present");
743 let part = out.find("#EXT-X-PART:").unwrap();
744 let extinf = out.find("#EXTINF:").unwrap();
745 assert!(tag < part && part < extinf, "{out}");
746 assert_eq!(out.matches(TAG).count(), 1);
747 assert!(out.find("#EXT-X-PART-INF").is_none_or(|i| i < tag), "{out}");
749 MediaPlaylist::parse(&out).expect("the spliced LL playlist parses");
750 }
751}