Skip to main content

ssai_runtime/
playlist.rs

1//! Per-session HLS Interstitial playlist rendering.
2//!
3//! `EXT-X-DATERANGE CLASS="com.apple.hls.interstitial"` — Appendix D §D.2 of
4//! draft-pantos-hls-rfc8216bis, transcribed in full at
5//! `broadcast-hls/docs/interstitials.md` (§D.2, examples §D.6/§D.7). Renders
6//! over `broadcast-hls`: [`render_session_playlist`] clones the primary
7//! [`MediaPlaylist`] and appends this session's interstitial tag line to
8//! [`MediaPlaylist::extra_tags`] — the injection point `broadcast-hls`
9//! documents for exactly this purpose ("Extra tag lines emitted verbatim
10//! before segment entries (e.g. `#EXT-X-DATERANGE:...`)").
11//!
12//! Implements the attribute set issue #929 scoped: `X-ASSET-URI`/
13//! `X-ASSET-LIST`, `X-RESUME-OFFSET`, `X-PLAYOUT-LIMIT`, `X-SNAP`,
14//! `X-RESTRICT`. `X-CONTENT-MAY-VARY`, `X-TIMELINE-OCCUPIES`,
15//! `X-TIMELINE-STYLE`, and the §D.3 skip-button-control attributes are not
16//! modeled.
17//!
18//! This crate does not do wall-clock math: [`InterstitialDateRange::start_date`]
19//! is a caller-supplied, already-formatted ISO-8601/RFC3339 string (the same
20//! convention `timed-metadata::daterange::DateRange` uses).
21
22use 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
29/// `CLASS` value for an Interstitial `EXT-X-DATERANGE` (Appendix D §D.2).
30pub const INTERSTITIAL_CLASS: &str = "com.apple.hls.interstitial";
31
32const TAG: &str = "#EXT-X-DATERANGE:";
33
34/// A rendered Interstitial `EXT-X-DATERANGE` tag: an [`AdBreakDecision`]
35/// plus the base-tag scheduling fields (`START-DATE`, `DURATION`, RFC 8216bis
36/// §4.4.5.1) the decision itself does not carry.
37#[derive(Debug, Clone, PartialEq)]
38pub struct InterstitialDateRange {
39    /// `ID` (quoted).
40    pub id: String,
41    /// `START-DATE` (quoted, ISO-8601/RFC3339) — caller-supplied; this
42    /// module does no wall-clock math.
43    pub start_date: String,
44    /// `DURATION` in seconds, if known.
45    pub duration: Option<f64>,
46    /// `X-ASSET-URI` or `X-ASSET-LIST`.
47    pub asset: AssetSource,
48    /// `X-RESUME-OFFSET` in seconds.
49    pub resume_offset: Option<f64>,
50    /// `X-PLAYOUT-LIMIT` in seconds.
51    pub playout_limit: Option<f64>,
52    /// `X-SNAP` identifiers.
53    pub snap: Vec<SnapMode>,
54    /// `X-RESTRICT` identifiers.
55    pub restrict: Vec<RestrictMode>,
56}
57
58impl InterstitialDateRange {
59    /// Build from an [`AdBreakDecision`] plus the two base-tag fields it
60    /// doesn't carry.
61    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    /// Render one `#EXT-X-DATERANGE:` line. Attribute order is fixed (`ID`,
79    /// `CLASS`, `START-DATE`, `DURATION`, `X-ASSET-URI`/`X-ASSET-LIST`,
80    /// `X-RESUME-OFFSET`, `X-PLAYOUT-LIMIT`, `X-SNAP`, `X-RESTRICT`) so
81    /// [`Self::parse_tag_line`] round-trips. Built solely from the typed
82    /// fields above — no stored source span is echoed.
83    ///
84    /// Every attribute value goes through [`AttrValue::quoted`]/[`bare`]
85    /// (issue #1140 / audit r14-SSAI-W1) before
86    /// [`broadcast_hls::render_attribute_list`] renders it: `id`,
87    /// `start_date` and the asset URI/list are frequently supplied (directly
88    /// or indirectly) by an [`crate::decision::AdDecisionProvider`] — a
89    /// third-party ad-decision service — so a `"`, CR or LF in any of them
90    /// is rejected here rather than terminating the attribute list and
91    /// injecting arbitrary tag lines into the session playlist. `duration`/
92    /// `resume_offset`/`playout_limit` must also be finite and non-negative
93    /// (audit r14-SSAI-W4) — `Error::InvalidDuration` otherwise.
94    ///
95    /// [`bare`]: AttrValue::bare
96    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        // `render_attribute_list` always prefixes each entry with `,`
156        // (broadcast-hls's convention for appending to an already-started
157        // attribute list); build the whole list through it and drop the
158        // single leading comma rather than special-casing the first entry.
159        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    /// Parse one `#EXT-X-DATERANGE:` line with
167    /// `CLASS="com.apple.hls.interstitial"`. Errors with
168    /// [`Error::TagParse`] if the `CLASS` doesn't match (or is missing) or a
169    /// required attribute is absent, and [`Error::InvalidAssetSource`] if
170    /// neither or both of `X-ASSET-URI`/`X-ASSET-LIST` are present.
171    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        // The shared workspace tokenizer (issue #1140 T12): `attrs` values
177        // already have their surrounding `"` stripped for quoted attributes.
178        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
231/// `#EXT-X-PROGRAM-DATE-TIME` (RFC 8216bis §4.4.4.3) — the tag an
232/// `EXT-X-DATERANGE` playlist must contain at least one of (§4.4.5.1).
233const PDT_TAG: &str = "#EXT-X-PROGRAM-DATE-TIME:";
234
235/// Whether `base` carries an `EXT-X-PROGRAM-DATE-TIME` (a segment's
236/// `pre_tags`, where [`MediaPlaylist::parse`] files it, or the
237/// playlist-level `extra_tags`).
238fn 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
245/// Clone `base` and append `active`'s rendered tag line (if any) to
246/// [`MediaPlaylist::extra_tags`] — the per-session playlist for one viewer.
247/// `base` is otherwise untouched: SSAI needs no per-viewer copy of the media
248/// itself (issue #929 design decision), only of this one tag line.
249///
250/// Fallible (issue #1140 / audit r14-SSAI-W1): an ad-decision-supplied value
251/// that fails [`InterstitialDateRange::to_tag_line`]'s validation is an
252/// error from this entry point, not a line silently dropped or injected.
253/// Also [`Error::MissingProgramDateTime`] when `active` is `Some` but `base`
254/// has no `EXT-X-PROGRAM-DATE-TIME` (RFC 8216bis §4.4.5.1; audit
255/// r14-SSAI-W3, issue #1125).
256///
257/// This deep-clones every segment per call; a per-viewer hot path should
258/// build one [`SessionPlaylistBase`] per base-playlist reload instead.
259pub 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/// A base playlist rendered **once**, so each viewer's per-session playlist
274/// costs one tag line plus a copy of the text — not a deep clone of every
275/// segment and a full re-serialisation per viewer per reload (audit
276/// r14-SSAI-O1, issue #1125).
277///
278/// The tag line is spliced in immediately before the first `#EXT-X-PART:` or
279/// `#EXTINF:` line, whichever comes first — i.e. before the first segment's
280/// part/`EXTINF` group, never between a segment's parts and its `EXTINF` (an
281/// `EXT-X-DATERANGE` is not positional within the playlist, RFC 8216bis
282/// §4.4.5.1) — or before `#EXT-X-ENDLIST` / at the end when the base has
283/// neither.
284#[derive(Debug, Clone)]
285pub struct SessionPlaylistBase {
286    head: String,
287    tail: String,
288    has_pdt: bool,
289}
290
291impl SessionPlaylistBase {
292    /// Render `base` once. Errors as [`MediaPlaylist::to_m3u8`].
293    pub fn new(base: &MediaPlaylist) -> Result<Self> {
294        let text = base.to_m3u8()?;
295        // `#EXT-X-PART:` (not `#EXT-X-PART-INF:`) or `#EXTINF:`, first of either.
296        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    /// The viewer-independent playlist text (no break).
312    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    /// Append one viewer's playlist to `out`: the base text, with
320    /// `active`'s tag line spliced in. Errors as [`render_session_playlist`];
321    /// on error nothing is appended.
322    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
346/// Format a non-negative, finite seconds value without a trailing `.0`,
347/// matching the spec examples (`X-RESUME-OFFSET=0`). Avoid `f64::fract()`
348/// (std-only intrinsic in `no_std`); use a cast comparison instead.
349///
350/// Rejects NaN/infinite/negative `v` (issue #1140 / audit r14-SSAI-W4):
351/// `NaN as i64 == 0` and `NaN != 0.0`, so an unchecked version of this
352/// function rendered `DURATION=NaN` — syntax the §4.2 grammar has no
353/// notation for.
354fn 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
366/// Parse a decimal-floating-point seconds attribute, rejecting a
367/// NaN/infinite/negative value at parse time (issue #1140 / audit
368/// r14-SSAI-W4) rather than letting it reach [`fmt_checked_secs`] on the
369/// next render.
370fn 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        // Appendix D §D.6 example, reproduced with this crate's own types.
425        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    /// Mutating any field must change the rendered output — the anti-cheat
461    /// property a raw-passthrough (source-span-echoing) serializer cannot
462    /// satisfy, since an echo can't reflect a mutation it didn't store.
463    #[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    /// Audit r14-SSAI-W1: a `"`, CR or LF in an ad-decision-supplied value
486    /// (here `X-ASSET-URI`) must be rejected, not injected into the
487    /// playlist as a broken attribute list or an extra tag line.
488    #[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    /// Audit r14-SSAI-W4: NaN/infinite/negative durations must be rejected,
509    /// never rendered as the bare token `NaN`/`inf`/a negative number.
510    #[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        // The base playlist itself (what every other viewer renders from)
595        // is untouched.
596        assert!(base.extra_tags.is_empty());
597        // Only the tag line differs; the segment list is byte-identical.
598        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    /// Audit r14-SSAI-W3: a DATERANGE needs a PDT in the playlist
624    /// (RFC 8216bis §4.4.5.1); both render paths refuse without one, and
625    /// accept the PDT wherever the base carries it.
626    #[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        // No break requested: no DATERANGE emitted, so no PDT needed.
642        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        // PDT as a playlist-level extra tag also satisfies the rule.
647        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    /// The spliced render must contain exactly the base text plus one tag
659    /// line, placed before the first EXTINF, and the result must reparse.
660    #[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        // An injection attempt errors and appends nothing.
678        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    /// A segment-less base with `#EXT-X-ENDLIST` must get the tag before it.
686    #[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    /// An LL-HLS base: the first segment's `EXT-X-PART` lines precede its
704    /// `EXTINF`, so the DATERANGE must land before the first PART, not between
705    /// a segment's parts and its `EXTINF`.
706    #[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        // The PART-INF header line is not mistaken for a part.
747        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}