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> {
98 let mut attrs = alloc::vec![
99 (String::from("ID"), AttrValue::quoted(self.id.clone())?),
100 (
101 String::from("CLASS"),
102 AttrValue::quoted(INTERSTITIAL_CLASS)?,
103 ),
104 (
105 String::from("START-DATE"),
106 AttrValue::quoted(self.start_date.clone())?,
107 ),
108 ];
109 if let Some(d) = self.duration {
110 attrs.push((
111 String::from("DURATION"),
112 AttrValue::bare(fmt_checked_secs("DURATION", d, Sign::NonNegative)?)?,
113 ));
114 }
115 match &self.asset {
116 AssetSource::Uri(uri) => {
117 attrs.push((String::from("X-ASSET-URI"), AttrValue::quoted(uri.clone())?));
118 }
119 AssetSource::List(uri) => {
120 attrs.push((
121 String::from("X-ASSET-LIST"),
122 AttrValue::quoted(uri.clone())?,
123 ));
124 }
125 }
126 if let Some(v) = self.resume_offset {
127 attrs.push((
128 String::from("X-RESUME-OFFSET"),
129 AttrValue::bare(fmt_checked_secs("X-RESUME-OFFSET", v, Sign::Signed)?)?,
130 ));
131 }
132 if let Some(v) = self.playout_limit {
133 attrs.push((
134 String::from("X-PLAYOUT-LIMIT"),
135 AttrValue::bare(fmt_checked_secs("X-PLAYOUT-LIMIT", v, Sign::NonNegative)?)?,
136 ));
137 }
138 if !self.snap.is_empty() {
139 for mode in &self.snap {
140 if let SnapMode::Other(t) = mode
141 && is_unsafe_list_token(t)
142 {
143 return Err(Error::TagParse("invalid X-SNAP token".to_string()));
144 }
145 }
146 let list = self
147 .snap
148 .iter()
149 .map(SnapMode::token)
150 .collect::<Vec<_>>()
151 .join(",");
152 attrs.push((String::from("X-SNAP"), AttrValue::quoted(list)?));
153 }
154 if !self.restrict.is_empty() {
155 for mode in &self.restrict {
156 if let RestrictMode::Other(t) = mode
157 && is_unsafe_list_token(t)
158 {
159 return Err(Error::TagParse("invalid X-RESTRICT token".to_string()));
160 }
161 }
162 let list = self
163 .restrict
164 .iter()
165 .map(RestrictMode::token)
166 .collect::<Vec<_>>()
167 .join(",");
168 attrs.push((String::from("X-RESTRICT"), AttrValue::quoted(list)?));
169 }
170 let mut body = String::new();
175 render_attribute_list(&mut body, &attrs);
176 let mut out = String::from(TAG);
177 out.push_str(body.trim_start_matches(','));
178 Ok(out)
179 }
180
181 pub fn parse_tag_line(s: &str) -> Result<Self> {
187 let body = s
188 .strip_prefix(TAG)
189 .ok_or_else(|| Error::TagParse("missing #EXT-X-DATERANGE: prefix".to_string()))?;
190
191 let (attrs, quoted) = parse_attribute_list_strict(body)?;
194 for (name, must_be_quoted) in ATTRIBUTE_QUOTING {
195 if attrs.contains_key(*name) && quoted.contains(*name) != *must_be_quoted {
196 return Err(Error::TagParse(alloc::format!(
197 "{name} must be {} (RFC 8216bis §4.2)",
198 if *must_be_quoted {
199 "a quoted-string"
200 } else {
201 "a decimal-floating-point, not quoted"
202 }
203 )));
204 }
205 }
206
207 let id = attrs.get("ID").cloned();
208 let class_ok = attrs.get("CLASS").map(String::as_str) == Some(INTERSTITIAL_CLASS);
209 let start_date = attrs.get("START-DATE").cloned();
210 let duration = attrs
211 .get("DURATION")
212 .map(|v| parse_checked_secs("DURATION", v, Sign::NonNegative))
213 .transpose()?;
214 let uri = attrs.get("X-ASSET-URI").cloned();
215 let list = attrs.get("X-ASSET-LIST").cloned();
216 let resume_offset = attrs
217 .get("X-RESUME-OFFSET")
218 .map(|v| parse_checked_secs("X-RESUME-OFFSET", v, Sign::Signed))
219 .transpose()?;
220 let playout_limit = attrs
221 .get("X-PLAYOUT-LIMIT")
222 .map(|v| parse_checked_secs("X-PLAYOUT-LIMIT", v, Sign::NonNegative))
223 .transpose()?;
224 let snap = attrs
225 .get("X-SNAP")
226 .map(|v| parse_snap_list(v))
227 .unwrap_or_default();
228 let restrict = attrs
229 .get("X-RESTRICT")
230 .map(|v| parse_restrict_list(v))
231 .unwrap_or_default();
232
233 if !class_ok {
234 return Err(Error::TagParse(
235 "not an Interstitial EXT-X-DATERANGE (CLASS missing or mismatched)".to_string(),
236 ));
237 }
238 let asset = match (uri, list) {
239 (Some(u), None) => AssetSource::Uri(u),
240 (None, Some(l)) => AssetSource::List(l),
241 _ => return Err(Error::InvalidAssetSource),
242 };
243
244 Ok(InterstitialDateRange {
245 id: id.ok_or_else(|| Error::TagParse("missing ID".to_string()))?,
246 start_date: start_date
247 .ok_or_else(|| Error::TagParse("missing START-DATE".to_string()))?,
248 duration,
249 asset,
250 resume_offset,
251 playout_limit,
252 snap,
253 restrict,
254 })
255 }
256}
257
258const PDT_TAG: &str = "#EXT-X-PROGRAM-DATE-TIME:";
261
262fn has_program_date_time(base: &MediaPlaylist) -> bool {
266 base.extra_tags.iter().any(|t| t.starts_with(PDT_TAG))
267 || base.segments.iter().any(|s| {
268 s.program_date_time.is_some() || s.pre_tags.iter().any(|t| t.starts_with(PDT_TAG))
269 })
270}
271
272pub fn render_session_playlist(
287 base: &MediaPlaylist,
288 active: Option<&InterstitialDateRange>,
289) -> Result<MediaPlaylist> {
290 let mut out = base.clone();
291 if let Some(dr) = active {
292 if !has_program_date_time(base) {
293 return Err(Error::MissingProgramDateTime);
294 }
295 out.extra_tags.push(dr.to_tag_line()?);
296 }
297 Ok(out)
298}
299
300#[derive(Debug, Clone)]
312pub struct SessionPlaylistBase {
313 head: String,
314 tail: String,
315 has_pdt: bool,
316}
317
318impl SessionPlaylistBase {
319 pub fn new(base: &MediaPlaylist) -> Result<Self> {
321 let text = base.to_m3u8()?;
322 let first_group = ["\n#EXT-X-PART:", "\n#EXTINF:"]
324 .iter()
325 .filter_map(|tag| text.find(tag))
326 .min();
327 let at = first_group
328 .or_else(|| text.rfind("\n#EXT-X-ENDLIST"))
329 .map_or(text.len(), |i| i + 1);
330 let (head, tail) = text.split_at(at);
331 Ok(SessionPlaylistBase {
332 head: head.to_string(),
333 tail: tail.to_string(),
334 has_pdt: has_program_date_time(base),
335 })
336 }
337
338 #[cfg(test)]
340 pub(crate) fn render_plain(&self) -> String {
341 let mut out = String::with_capacity(self.head.len() + self.tail.len());
342 out.push_str(&self.head);
343 out.push_str(&self.tail);
344 out
345 }
346
347 pub fn render_into(
351 &self,
352 out: &mut String,
353 active: Option<&InterstitialDateRange>,
354 ) -> Result<()> {
355 let line = match active {
356 Some(dr) => {
357 if !self.has_pdt {
358 return Err(Error::MissingProgramDateTime);
359 }
360 Some(dr.to_tag_line()?)
361 }
362 None => None,
363 };
364 out.push_str(&self.head);
365 if let Some(line) = line {
366 out.push_str(&line);
367 out.push('\n');
368 }
369 out.push_str(&self.tail);
370 Ok(())
371 }
372}
373
374fn fmt_checked_secs(what: &'static str, v: f64, sign: Sign) -> Result<String> {
383 if !sign.admits(v) {
384 return Err(Error::InvalidDuration { what, value: v });
385 }
386 if v.abs() < EXACT_INT_LIMIT {
387 let trunc = v as i64;
388 if v == trunc as f64 {
389 return Ok(format!("{trunc}"));
390 }
391 }
392 Ok(format!("{v}"))
393}
394
395const EXACT_INT_LIMIT: f64 = 9_007_199_254_740_992.0;
398
399#[derive(Clone, Copy)]
400enum Sign {
401 NonNegative,
402 Signed,
403}
404
405impl Sign {
406 fn admits(self, v: f64) -> bool {
407 v.is_finite() && (matches!(self, Sign::Signed) || v >= 0.0)
408 }
409}
410
411fn parse_checked_secs(what: &'static str, v: &str, sign: Sign) -> Result<f64> {
416 let parsed: f64 = v
417 .parse()
418 .map_err(|_| Error::TagParse(format!("bad number: {v}")))?;
419 if !sign.admits(parsed) {
420 return Err(Error::InvalidDuration {
421 what,
422 value: parsed,
423 });
424 }
425 Ok(parsed)
426}
427
428fn is_unsafe_list_token(token: &str) -> bool {
429 token.contains(['"', ',', '\r', '\n'])
430}
431
432fn parse_snap_list(v: &str) -> Vec<SnapMode> {
433 v.split(',')
434 .map(str::trim)
435 .filter(|tok| !tok.is_empty())
436 .map(|tok| match tok {
437 "OUT" => SnapMode::Out,
438 "IN" => SnapMode::In,
439 other => SnapMode::Other(other.to_string()),
440 })
441 .collect()
442}
443
444fn parse_restrict_list(v: &str) -> Vec<RestrictMode> {
445 v.split(',')
446 .map(str::trim)
447 .filter(|tok| !tok.is_empty())
448 .map(|tok| match tok {
449 "SKIP" => RestrictMode::Skip,
450 "JUMP" => RestrictMode::Jump,
451 other => RestrictMode::Other(other.to_string()),
452 })
453 .collect()
454}
455
456const ATTRIBUTE_QUOTING: &[(&str, bool)] = &[
459 ("ID", true),
460 ("CLASS", true),
461 ("START-DATE", true),
462 ("X-ASSET-URI", true),
463 ("X-ASSET-LIST", true),
464 ("X-SNAP", true),
465 ("X-RESTRICT", true),
466 ("DURATION", false),
467 ("X-RESUME-OFFSET", false),
468 ("X-PLAYOUT-LIMIT", false),
469];
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use alloc::vec;
475 use broadcast_hls::{DecimalSeconds, MediaSegment};
476
477 fn sample() -> InterstitialDateRange {
478 InterstitialDateRange {
479 id: "ad1".to_string(),
480 start_date: "2020-01-02T21:55:44.000Z".to_string(),
481 duration: Some(15.0),
482 asset: AssetSource::Uri("http://example.com/ad1.m3u8".to_string()),
483 resume_offset: Some(0.0),
484 playout_limit: None,
485 snap: Vec::new(),
486 restrict: vec![RestrictMode::Skip, RestrictMode::Jump],
487 }
488 }
489
490 #[test]
491 fn tag_line_matches_the_spec_example_shape() {
492 let line = sample().to_tag_line().unwrap();
494 assert!(line.starts_with(TAG));
495 assert!(line.contains(r#"CLASS="com.apple.hls.interstitial""#));
496 assert!(line.contains(r#"X-ASSET-URI="http://example.com/ad1.m3u8""#));
497 assert!(line.contains("X-RESUME-OFFSET=0"));
498 assert!(line.contains(r#"X-RESTRICT="SKIP,JUMP""#));
499 }
500
501 #[test]
502 fn tag_line_round_trips() {
503 let dr = sample();
504 let line = dr.to_tag_line().unwrap();
505 let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
506 assert_eq!(back, dr);
507 }
508
509 #[test]
510 fn asset_list_variant_round_trips() {
511 let dr = InterstitialDateRange {
512 id: "ad2".to_string(),
513 start_date: "2020-01-02T21:55:44.000Z".to_string(),
514 duration: Some(30.0),
515 asset: AssetSource::List("http://example.com/adv.json".to_string()),
516 resume_offset: None,
517 playout_limit: Some(20.0),
518 snap: vec![SnapMode::Out, SnapMode::In],
519 restrict: Vec::new(),
520 };
521 let line = dr.to_tag_line().unwrap();
522 assert!(line.contains(r#"X-ASSET-LIST="http://example.com/adv.json""#));
523 assert!(line.contains(r#"X-SNAP="OUT,IN""#));
524 let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
525 assert_eq!(back, dr);
526 }
527
528 #[test]
532 fn mutating_a_field_changes_the_output() {
533 let base = sample();
534 let base_line = base.to_tag_line().unwrap();
535
536 let mut mutated = base.clone();
537 mutated.id = "different-id".to_string();
538 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
539
540 let mut mutated = base.clone();
541 mutated.resume_offset = Some(5.0);
542 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
543
544 let mut mutated = base.clone();
545 mutated.restrict = vec![RestrictMode::Skip];
546 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
547
548 let mut mutated = base;
549 mutated.asset = AssetSource::Uri("http://example.com/different.m3u8".to_string());
550 assert_ne!(mutated.to_tag_line().unwrap(), base_line);
551 }
552
553 #[test]
557 fn to_tag_line_rejects_injection_in_asset_uri() {
558 let mut dr = sample();
559 dr.asset = AssetSource::Uri("http://example.com/ad1.m3u8\"\r\n#EXT-X-ENDLIST".to_string());
560 assert!(matches!(
561 dr.to_tag_line().unwrap_err(),
562 Error::HlsAttrValue(_)
563 ));
564 }
565
566 #[test]
567 fn to_tag_line_rejects_injection_in_id() {
568 let mut dr = sample();
569 dr.id = "ad\"1".to_string();
570 assert!(matches!(
571 dr.to_tag_line().unwrap_err(),
572 Error::HlsAttrValue(_)
573 ));
574 }
575
576 #[test]
579 fn to_tag_line_rejects_non_finite_and_negative_durations() {
580 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
581 let mut dr = sample();
582 dr.duration = Some(bad);
583 let err = dr.to_tag_line().unwrap_err();
584 assert!(
585 matches!(err, Error::InvalidDuration { .. }),
586 "duration {bad} should be rejected, got {err:?}"
587 );
588
589 if bad != -1.0 {
590 let mut dr = sample();
591 dr.resume_offset = Some(bad);
592 assert!(matches!(
593 dr.to_tag_line().unwrap_err(),
594 Error::InvalidDuration { .. }
595 ));
596 }
597
598 let mut dr = sample();
599 dr.playout_limit = Some(bad);
600 assert!(matches!(
601 dr.to_tag_line().unwrap_err(),
602 Error::InvalidDuration { .. }
603 ));
604 }
605 }
606
607 #[test]
608 fn to_tag_line_rejects_unsafe_other_list_tokens() {
609 for bad in ["a\"b", "a,b", "a\rb", "a\nb"] {
610 let mut dr = sample();
611 dr.snap = vec![SnapMode::Other(bad.to_string())];
612 assert!(
613 matches!(dr.to_tag_line().unwrap_err(), Error::TagParse(_)),
614 "snap {bad:?}"
615 );
616 let mut dr = sample();
617 dr.restrict = vec![RestrictMode::Other(bad.to_string())];
618 assert!(
619 matches!(dr.to_tag_line().unwrap_err(), Error::TagParse(_)),
620 "restrict {bad:?}"
621 );
622 }
623 let mut dr = sample();
624 dr.snap = vec![SnapMode::Other("X-CUSTOM".to_string())];
625 assert!(dr.to_tag_line().is_ok());
626 }
627
628 #[test]
629 fn snap_and_restrict_lists_trim_tokens_and_drop_empty_ones() {
630 let line = sample().to_tag_line().unwrap();
631 let with = |attr: &str| {
632 let base = line.replace(",X-RESTRICT=\"SKIP,JUMP\"", "");
633 format!("{base},{attr}")
634 };
635 let dr = InterstitialDateRange::parse_tag_line(&with("X-SNAP=\" OUT , IN \"")).unwrap();
636 assert_eq!(dr.snap, vec![SnapMode::Out, SnapMode::In]);
637 let dr = InterstitialDateRange::parse_tag_line(&with("X-SNAP=\"OUT,\"")).unwrap();
638 assert_eq!(dr.snap, vec![SnapMode::Out]);
639 let dr =
640 InterstitialDateRange::parse_tag_line(&with("X-RESTRICT=\" SKIP , JUMP \"")).unwrap();
641 assert_eq!(dr.restrict, vec![RestrictMode::Skip, RestrictMode::Jump]);
642 let dr = InterstitialDateRange::parse_tag_line(&with("X-RESTRICT=\"SKIP,\"")).unwrap();
643 assert_eq!(dr.restrict, vec![RestrictMode::Skip]);
644 }
645
646 #[test]
647 fn negative_resume_offset_round_trips() {
648 let mut dr = sample();
649 dr.resume_offset = Some(-5.0);
650 let line = dr.to_tag_line().unwrap();
651 assert!(line.contains("X-RESUME-OFFSET=-5"), "{line}");
652 let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
653 assert_eq!(back.resume_offset, Some(-5.0));
654 }
655
656 #[test]
657 fn parse_accepts_negative_resume_offset() {
658 let s = "#EXT-X-DATERANGE:ID=\"a\",CLASS=\"com.apple.hls.interstitial\",\
659 START-DATE=\"2020-01-02T21:55:44.000Z\",X-ASSET-URI=\"http://x/a\",\
660 X-RESUME-OFFSET=-3";
661 let parsed = InterstitialDateRange::parse_tag_line(s).unwrap();
662 assert_eq!(parsed.resume_offset, Some(-3.0));
663 }
664
665 #[test]
666 fn negative_playout_limit_is_still_rejected() {
667 let mut dr = sample();
668 dr.playout_limit = Some(-1.0);
669 assert!(matches!(
670 dr.to_tag_line().unwrap_err(),
671 Error::InvalidDuration { .. }
672 ));
673 }
674
675 #[test]
676 fn duration_beyond_exact_integer_range_renders_exactly() {
677 let mut dr = sample();
678 dr.duration = Some(9_223_372_036_854_775_808.0);
679 let line = dr.to_tag_line().unwrap();
680 assert!(!line.contains("DURATION=9223372036854775807"), "{line}");
681 let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
682 assert_eq!(back.duration, Some(9_223_372_036_854_775_808.0));
683 }
684
685 #[test]
686 fn parse_rejects_non_finite_duration() {
687 let line = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
688 START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\",\
689 DURATION=nan";
690 assert!(matches!(
691 InterstitialDateRange::parse_tag_line(line).unwrap_err(),
692 Error::InvalidDuration { .. }
693 ));
694 }
695
696 #[test]
697 fn parse_rejects_wrong_or_missing_class() {
698 let line = "#EXT-X-DATERANGE:ID=\"x\",START-DATE=\"2020-01-01T00:00:00Z\",\
699 X-ASSET-URI=\"http://x/a.m3u8\"";
700 let err = InterstitialDateRange::parse_tag_line(line).unwrap_err();
701 assert!(matches!(err, Error::TagParse(_)));
702 }
703
704 #[test]
705 fn parse_rejects_both_or_neither_asset_source() {
706 let both = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
707 START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\",\
708 X-ASSET-LIST=\"http://x/list.json\"";
709 assert!(matches!(
710 InterstitialDateRange::parse_tag_line(both).unwrap_err(),
711 Error::InvalidAssetSource
712 ));
713
714 let neither = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
715 START-DATE=\"2020-01-01T00:00:00Z\"";
716 assert!(matches!(
717 InterstitialDateRange::parse_tag_line(neither).unwrap_err(),
718 Error::InvalidAssetSource
719 ));
720 }
721
722 #[test]
723 fn render_session_playlist_appends_only_for_the_active_session() {
724 let mut base = MediaPlaylist {
725 target_duration: 6,
726 ..Default::default()
727 };
728 base.segments.push(MediaSegment {
729 duration: DecimalSeconds::new(6.0).unwrap(),
730 uri: "main.ts".to_string(),
731 ..Default::default()
732 });
733
734 base.segments[0].pre_tags = vec![PDT_LINE.to_string()];
735
736 let dr = sample();
737 let with_break = render_session_playlist(&base, Some(&dr)).unwrap();
738 let without_break = render_session_playlist(&base, None).unwrap();
739
740 assert!(with_break.to_m3u8().unwrap().contains("X-ASSET-URI"));
741 assert!(!without_break.to_m3u8().unwrap().contains("X-ASSET-URI"));
742 assert!(base.extra_tags.is_empty());
745 assert_eq!(with_break.segments, without_break.segments);
747 }
748
749 const PDT_LINE: &str = "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z";
750
751 fn base_with_segments(n: usize, pdt: bool) -> MediaPlaylist {
752 let mut base = MediaPlaylist {
753 target_duration: 6,
754 ..Default::default()
755 };
756 for i in 0..n {
757 base.segments.push(MediaSegment {
758 duration: DecimalSeconds::new(6.0).unwrap(),
759 uri: format!("main{i}.ts"),
760 pre_tags: if pdt && i == 0 {
761 vec![PDT_LINE.to_string()]
762 } else {
763 Vec::new()
764 },
765 ..Default::default()
766 });
767 }
768 base
769 }
770
771 #[test]
775 fn daterange_without_pdt_is_an_error_on_both_paths() {
776 let dr = sample();
777 let no_pdt = base_with_segments(2, false);
778 assert!(matches!(
779 render_session_playlist(&no_pdt, Some(&dr)).unwrap_err(),
780 Error::MissingProgramDateTime
781 ));
782 let b = SessionPlaylistBase::new(&no_pdt).unwrap();
783 let mut out = String::new();
784 assert!(matches!(
785 b.render_into(&mut out, Some(&dr)).unwrap_err(),
786 Error::MissingProgramDateTime
787 ));
788 assert!(out.is_empty(), "nothing appended on error");
789 assert!(render_session_playlist(&no_pdt, None).is_ok());
791 b.render_into(&mut out, None).unwrap();
792 assert_eq!(out, no_pdt.to_m3u8().unwrap());
793
794 let mut extra = base_with_segments(1, false);
796 extra.extra_tags.push(PDT_LINE.to_string());
797 assert!(render_session_playlist(&extra, Some(&dr)).is_ok());
798 assert!(
799 SessionPlaylistBase::new(&extra)
800 .unwrap()
801 .render_into(&mut String::new(), Some(&dr))
802 .is_ok()
803 );
804 }
805
806 #[test]
809 fn session_playlist_base_splices_one_line_before_the_first_extinf() {
810 let base = base_with_segments(3, true);
811 let dr = sample();
812 let plain = base.to_m3u8().unwrap();
813 let b = SessionPlaylistBase::new(&base).unwrap();
814 assert_eq!(b.render_plain(), plain);
815
816 let mut out = String::new();
817 b.render_into(&mut out, Some(&dr)).unwrap();
818 let line = dr.to_tag_line().unwrap();
819 let expected = plain.replacen("#EXTINF:", &format!("{line}\n#EXTINF:"), 1);
820 assert_eq!(out, expected);
821 assert_eq!(out.lines().filter(|l| l.starts_with(TAG)).count(), 1);
822 let reparsed = MediaPlaylist::parse(&out).unwrap();
823 assert_eq!(reparsed.segments.len(), 3);
824
825 let mut bad = sample();
827 bad.asset = AssetSource::Uri("x\"\n#EXT-X-ENDLIST".to_string());
828 let mut out2 = String::from("keep");
829 assert!(b.render_into(&mut out2, Some(&bad)).is_err());
830 assert_eq!(out2, "keep");
831 }
832
833 #[test]
835 fn session_playlist_base_without_segments_inserts_before_endlist() {
836 let mut base = MediaPlaylist {
837 target_duration: 6,
838 endlist: true,
839 ..Default::default()
840 };
841 base.extra_tags.push(PDT_LINE.to_string());
842 let b = SessionPlaylistBase::new(&base).unwrap();
843 let mut out = String::new();
844 b.render_into(&mut out, Some(&sample())).unwrap();
845 let lines: Vec<&str> = out.lines().collect();
846 let n = lines.len();
847 assert_eq!(lines[n - 1], "#EXT-X-ENDLIST");
848 assert!(lines[n - 2].starts_with(TAG));
849 }
850
851 #[test]
855 fn low_latency_base_gets_the_tag_before_the_first_part() {
856 use broadcast_hls::{LowLatencyConfig, PartSpec};
857 let mut base = base_with_segments(2, true);
858 for seg in &mut base.segments {
859 seg.parts = vec![
860 PartSpec {
861 uri: format!("{}.0.m4s", seg.uri),
862 duration: DecimalSeconds::new(3.0).unwrap(),
863 ..Default::default()
864 },
865 PartSpec {
866 uri: format!("{}.1.m4s", seg.uri),
867 duration: DecimalSeconds::new(3.0).unwrap(),
868 ..Default::default()
869 },
870 ];
871 }
872 base.low_latency = Some(LowLatencyConfig {
873 part_target: Some(DecimalSeconds::new(3.0).unwrap()),
874 ..Default::default()
875 });
876 let plain = base.to_m3u8().unwrap();
877 let first_part = plain.find("#EXT-X-PART:").expect("the base has parts");
878 let first_extinf = plain.find("#EXTINF:").unwrap();
879 assert!(
880 first_part < first_extinf,
881 "premise: parts precede EXTINF\n{plain}"
882 );
883
884 let mut out = String::new();
885 SessionPlaylistBase::new(&base)
886 .unwrap()
887 .render_into(&mut out, Some(&sample()))
888 .unwrap();
889 let tag = out.find(TAG).expect("tag present");
890 let part = out.find("#EXT-X-PART:").unwrap();
891 let extinf = out.find("#EXTINF:").unwrap();
892 assert!(tag < part && part < extinf, "{out}");
893 assert_eq!(out.matches(TAG).count(), 1);
894 assert!(out.find("#EXT-X-PART-INF").is_none_or(|i| i < tag), "{out}");
896 MediaPlaylist::parse(&out).expect("the spliced LL playlist parses");
897 }
898
899 #[test]
900 fn parse_tag_line_rejects_malformed_attribute_list_unterminated_quote() {
901 let line = "#EXT-X-DATERANGE:ID=\"x";
902 assert!(matches!(
903 InterstitialDateRange::parse_tag_line(line).unwrap_err(),
904 Error::HlsAttrValue(broadcast_hls::Error::UnterminatedQuotedString { .. })
905 ));
906 }
907
908 #[test]
909 fn parse_tag_line_rejects_malformed_attribute_list_duplicate_key() {
910 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\"";
911 assert!(matches!(
912 InterstitialDateRange::parse_tag_line(line).unwrap_err(),
913 Error::HlsAttrValue(broadcast_hls::Error::DuplicateAttribute { name }) if name == "ID"
914 ));
915 }
916
917 const LINE_PREFIX: &str = "#EXT-X-DATERANGE:CLASS=\"com.apple.hls.interstitial\",START-DATE=\"2020-01-02T21:55:44.000Z\",X-ASSET-URI=\"https://x/a.m3u8\"";
918
919 #[test]
920 fn parse_rejects_an_unquoted_id_and_a_quoted_duration() {
921 for extra in [",ID=ad1", ",ID=\"ad1\",DURATION=\"15\""] {
922 let line = alloc::format!("{LINE_PREFIX}{extra}");
923 assert!(
924 matches!(
925 InterstitialDateRange::parse_tag_line(&line),
926 Err(Error::TagParse(_))
927 ),
928 "{line}"
929 );
930 }
931 let ok = alloc::format!("{LINE_PREFIX},ID=\"ad1\",DURATION=15");
932 assert!(InterstitialDateRange::parse_tag_line(&ok).is_ok());
933 }
934
935 #[test]
936 fn unknown_snap_and_restrict_identifiers_round_trip_byte_identical() {
937 let line = alloc::format!(
938 "{LINE_PREFIX},ID=\"ad1\",X-SNAP=\"OUT,SIDEWAYS\",X-RESTRICT=\"SKIP,TELEPORT\""
939 );
940 let parsed = InterstitialDateRange::parse_tag_line(&line).unwrap();
941 assert_eq!(parsed.snap[1], SnapMode::Other("SIDEWAYS".to_string()));
942 let again = parsed.to_tag_line().unwrap();
943 let reparsed = InterstitialDateRange::parse_tag_line(&again).unwrap();
944 assert_eq!(reparsed, parsed);
945 assert!(again.contains("X-SNAP=\"OUT,SIDEWAYS\""), "{again}");
946 assert!(again.contains("X-RESTRICT=\"SKIP,TELEPORT\""), "{again}");
947 }
948}