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, 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(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
241            .segments
242            .iter()
243            .any(|s| s.pre_tags.iter().any(|t| t.starts_with(PDT_TAG)))
244}
245
246/// Clone `base` and append `active`'s rendered tag line (if any) to
247/// [`MediaPlaylist::extra_tags`] — the per-session playlist for one viewer.
248/// `base` is otherwise untouched: SSAI needs no per-viewer copy of the media
249/// itself (issue #929 design decision), only of this one tag line.
250///
251/// Fallible (issue #1140 / audit r14-SSAI-W1): an ad-decision-supplied value
252/// that fails [`InterstitialDateRange::to_tag_line`]'s validation is an
253/// error from this entry point, not a line silently dropped or injected.
254/// Also [`Error::MissingProgramDateTime`] when `active` is `Some` but `base`
255/// has no `EXT-X-PROGRAM-DATE-TIME` (RFC 8216bis §4.4.5.1; audit
256/// r14-SSAI-W3, issue #1125).
257///
258/// This deep-clones every segment per call; a per-viewer hot path should
259/// build one [`SessionPlaylistBase`] per base-playlist reload instead.
260pub 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/// A base playlist rendered **once**, so each viewer's per-session playlist
275/// costs one tag line plus a copy of the text — not a deep clone of every
276/// segment and a full re-serialisation per viewer per reload (audit
277/// r14-SSAI-O1, issue #1125).
278///
279/// The tag line is spliced in immediately before the first `#EXT-X-PART:` or
280/// `#EXTINF:` line, whichever comes first — i.e. before the first segment's
281/// part/`EXTINF` group, never between a segment's parts and its `EXTINF` (an
282/// `EXT-X-DATERANGE` is not positional within the playlist, RFC 8216bis
283/// §4.4.5.1) — or before `#EXT-X-ENDLIST` / at the end when the base has
284/// neither.
285#[derive(Debug, Clone)]
286pub struct SessionPlaylistBase {
287    head: String,
288    tail: String,
289    has_pdt: bool,
290}
291
292impl SessionPlaylistBase {
293    /// Render `base` once. Errors as [`MediaPlaylist::to_m3u8`].
294    pub fn new(base: &MediaPlaylist) -> Result<Self> {
295        let text = base.to_m3u8()?;
296        // `#EXT-X-PART:` (not `#EXT-X-PART-INF:`) or `#EXTINF:`, first of either.
297        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    /// The viewer-independent playlist text (no break).
313    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    /// Append one viewer's playlist to `out`: the base text, with
321    /// `active`'s tag line spliced in. Errors as [`render_session_playlist`];
322    /// on error nothing is appended.
323    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
347/// Format a non-negative, finite seconds value without a trailing `.0`,
348/// matching the spec examples (`X-RESUME-OFFSET=0`). Avoid `f64::fract()`
349/// (std-only intrinsic in `no_std`); use a cast comparison instead.
350///
351/// Rejects NaN/infinite/negative `v` (issue #1140 / audit r14-SSAI-W4):
352/// `NaN as i64 == 0` and `NaN != 0.0`, so an unchecked version of this
353/// function rendered `DURATION=NaN` — syntax the §4.2 grammar has no
354/// notation for.
355fn 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
367/// Parse a decimal-floating-point seconds attribute, rejecting a
368/// NaN/infinite/negative value at parse time (issue #1140 / audit
369/// r14-SSAI-W4) rather than letting it reach [`fmt_checked_secs`] on the
370/// next render.
371fn 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        // Appendix D §D.6 example, reproduced with this crate's own types.
426        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    /// Mutating any field must change the rendered output — the anti-cheat
462    /// property a raw-passthrough (source-span-echoing) serializer cannot
463    /// satisfy, since an echo can't reflect a mutation it didn't store.
464    #[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    /// Audit r14-SSAI-W1: a `"`, CR or LF in an ad-decision-supplied value
487    /// (here `X-ASSET-URI`) must be rejected, not injected into the
488    /// playlist as a broken attribute list or an extra tag line.
489    #[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    /// Audit r14-SSAI-W4: NaN/infinite/negative durations must be rejected,
510    /// never rendered as the bare token `NaN`/`inf`/a negative number.
511    #[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        // The base playlist itself (what every other viewer renders from)
596        // is untouched.
597        assert!(base.extra_tags.is_empty());
598        // Only the tag line differs; the segment list is byte-identical.
599        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    /// Audit r14-SSAI-W3: a DATERANGE needs a PDT in the playlist
625    /// (RFC 8216bis §4.4.5.1); both render paths refuse without one, and
626    /// accept the PDT wherever the base carries it.
627    #[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        // No break requested: no DATERANGE emitted, so no PDT needed.
643        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        // PDT as a playlist-level extra tag also satisfies the rule.
648        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    /// The spliced render must contain exactly the base text plus one tag
660    /// line, placed before the first EXTINF, and the result must reparse.
661    #[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        // An injection attempt errors and appends nothing.
679        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    /// A segment-less base with `#EXT-X-ENDLIST` must get the tag before it.
687    #[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    /// An LL-HLS base: the first segment's `EXT-X-PART` lines precede its
705    /// `EXTINF`, so the DATERANGE must land before the first PART, not between
706    /// a segment's parts and its `EXTINF`.
707    #[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        // The PART-INF header line is not mistaken for a part.
748        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}