ssai_runtime/decision.rs
1//! Ad-break decision types.
2//!
3//! Models the subset of the HLS Interstitials `EXT-X-DATERANGE` attribute
4//! set (`CLASS="com.apple.hls.interstitial"`, draft-pantos-hls-rfc8216bis
5//! Appendix D §D.2, transcribed in full at
6//! `private/specs-verified/hls/ietf/rfc8216bis-interstitials.md`) issue #929 scoped:
7//! `X-ASSET-URI`/`X-ASSET-LIST`, `X-RESUME-OFFSET`, `X-PLAYOUT-LIMIT`,
8//! `X-SNAP`, `X-RESTRICT`. `X-CONTENT-MAY-VARY`, `X-TIMELINE-OCCUPIES`,
9//! `X-TIMELINE-STYLE`, and the §D.3 skip-button-control attributes are not
10//! modeled yet — a later addition, not a correction of what is here.
11//!
12//! The ad-decision server itself (VAST/VMAP, or anything else) is a caller
13//! concern per the issue's design decision: [`AdDecisionProvider`] is the
14//! only extension point this crate defines, and it performs no I/O — this
15//! crate has no HTTP client.
16
17use crate::error::Result;
18use alloc::string::String;
19use alloc::vec::Vec;
20
21/// `X-SNAP` identifier (Appendix D §D.2).
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[non_exhaustive]
25pub enum SnapMode {
26 /// `OUT` — the client should locate the primary-content segment
27 /// boundary nearest the interstitial's `START-DATE` and transition
28 /// there.
29 Out,
30 /// `IN` — the client should locate the primary-content segment boundary
31 /// nearest the scheduled resumption point and resume there.
32 In,
33 /// An identifier this crate does not know, kept verbatim so a parse then
34 /// render is lossless and a newer value is not an error.
35 Other(String),
36}
37
38impl SnapMode {
39 /// Stable label; `"other"` for [`SnapMode::Other`] (see [`Self::token`]).
40 pub fn name(&self) -> &'static str {
41 match self {
42 SnapMode::Out => "OUT",
43 SnapMode::In => "IN",
44 SnapMode::Other(_) => "other",
45 }
46 }
47
48 /// The literal `X-SNAP` wire token, including an unknown one.
49 pub fn token(&self) -> &str {
50 match self {
51 SnapMode::Other(t) => t,
52 known => known.name(),
53 }
54 }
55}
56broadcast_common::impl_spec_display!(SnapMode);
57
58/// `X-RESTRICT` identifier (Appendix D §D.2).
59#[derive(Debug, Clone, PartialEq, Eq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61#[non_exhaustive]
62pub enum RestrictMode {
63 /// `SKIP` — the client must not allow seeking forward or fast playback
64 /// while the interstitial plays.
65 Skip,
66 /// `JUMP` — the client must not allow seeking across the interstitial's
67 /// `START-DATE` without first playing it.
68 Jump,
69 /// An identifier this crate does not know, kept verbatim so a parse then
70 /// render is lossless and a newer value is not an error.
71 Other(String),
72}
73
74impl RestrictMode {
75 /// Stable label; `"other"` for [`RestrictMode::Other`] (see [`Self::token`]).
76 pub fn name(&self) -> &'static str {
77 match self {
78 RestrictMode::Skip => "SKIP",
79 RestrictMode::Jump => "JUMP",
80 RestrictMode::Other(_) => "other",
81 }
82 }
83
84 /// The literal `X-RESTRICT` wire token, including an unknown one.
85 pub fn token(&self) -> &str {
86 match self {
87 RestrictMode::Other(t) => t,
88 known => known.name(),
89 }
90 }
91}
92broadcast_common::impl_spec_display!(RestrictMode);
93
94/// Which of the mutually-exclusive `X-ASSET-URI` / `X-ASSET-LIST` attributes
95/// identifies the interstitial content. Appendix D §D.2: "An Interstitial
96/// `EXT-X-DATERANGE` tag MUST have either `X-ASSET-URI` or `X-ASSET-LIST`; it
97/// MUST NOT have both."
98#[derive(Debug, Clone, PartialEq, Eq)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100#[non_exhaustive]
101pub enum AssetSource {
102 /// `X-ASSET-URI` — a single interstitial asset's absolute URI.
103 Uri(String),
104 /// `X-ASSET-LIST` — URI of a JSON asset-list resource (the §D.2
105 /// `X-ASSET-LIST` JSON schema). This crate carries the URI only; it does
106 /// not fetch or parse that JSON — doing so is HTTP, and this crate is
107 /// sans-IO.
108 List(String),
109}
110
111/// The decision for one ad break: which interstitial content to schedule and
112/// how the client should treat it, per Appendix D §D.2.
113// `resume_offset`/`playout_limit` are `f64`, so this is `PartialEq` only (no
114// `Eq`) — the same reason `broadcast_hls::MediaPlaylist` and
115// `timed_metadata::DateRange` are `PartialEq`-only.
116#[derive(Debug, Clone, PartialEq)]
117#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
118pub struct AdBreakDecision {
119 /// `ID` of the rendered `EXT-X-DATERANGE` tag.
120 pub id: String,
121 /// `X-ASSET-URI` or `X-ASSET-LIST`.
122 pub asset: AssetSource,
123 /// `X-RESUME-OFFSET` in seconds; `None` = attribute omitted (the client
124 /// then treats it as the interstitial's duration, per §D.2).
125 pub resume_offset: Option<f64>,
126 /// `X-PLAYOUT-LIMIT` in seconds; `None` = attribute omitted.
127 pub playout_limit: Option<f64>,
128 /// `X-SNAP` identifiers, in the order they should be rendered.
129 pub snap: Vec<SnapMode>,
130 /// `X-RESTRICT` identifiers, in the order they should be rendered.
131 pub restrict: Vec<RestrictMode>,
132}
133
134impl AdBreakDecision {
135 /// Build a decision for a single-asset (`X-ASSET-URI`) interstitial,
136 /// with no resume offset, playout limit, snap, or restrict attributes —
137 /// set those fields afterwards as needed.
138 pub fn single_asset(id: impl Into<String>, uri: impl Into<String>) -> Self {
139 AdBreakDecision {
140 id: id.into(),
141 asset: AssetSource::Uri(uri.into()),
142 resume_offset: None,
143 playout_limit: None,
144 snap: Vec::new(),
145 restrict: Vec::new(),
146 }
147 }
148
149 /// Build a decision for an asset-list (`X-ASSET-LIST`) interstitial.
150 #[cfg(test)]
151 pub(crate) fn asset_list(id: impl Into<String>, list_uri: impl Into<String>) -> Self {
152 AdBreakDecision {
153 id: id.into(),
154 asset: AssetSource::List(list_uri.into()),
155 resume_offset: None,
156 playout_limit: None,
157 snap: Vec::new(),
158 restrict: Vec::new(),
159 }
160 }
161}
162
163/// Context handed to [`AdDecisionProvider::decide`]: what the SCTE-35 cue
164/// told us about the upcoming break, plus which session is asking.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct BreakContext {
167 /// The session requesting a decision.
168 pub session_id: String,
169 /// `splice_event_id` from the triggering `splice_insert()`, if known.
170 pub splice_event_id: Option<u32>,
171 /// `out_of_network_indicator` — `true` for break start, `false` for
172 /// return-to-network.
173 pub out_of_network: bool,
174 /// `break_duration().duration`, in 90 kHz ticks, if the cue carried one.
175 pub break_duration_ticks: Option<u64>,
176 /// The (conditioned) media-timeline instant this break is scheduled to
177 /// start at, in the caller's clock unit (typically 90 kHz ticks).
178 pub splice_point_pts: u64,
179}
180
181/// Pluggable ad-decision hook (issue #929 design decision): VAST/VMAP or any
182/// other decisioning scheme is entirely the caller's concern. This trait's
183/// only job is to hand back what to render — it performs no I/O itself, and
184/// this crate adds no HTTP client to reach one.
185pub trait AdDecisionProvider {
186 /// Decide what interstitial content to schedule for `ctx`.
187 fn decide(&self, ctx: &BreakContext) -> Result<AdBreakDecision>;
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 struct AlwaysSameAd;
195 impl AdDecisionProvider for AlwaysSameAd {
196 fn decide(&self, ctx: &BreakContext) -> Result<AdBreakDecision> {
197 Ok(AdBreakDecision::single_asset(
198 alloc::format!("break-{}", ctx.splice_event_id.unwrap_or(0)),
199 "https://ads.example.com/creative.m3u8",
200 ))
201 }
202 }
203
204 #[test]
205 fn provider_trait_is_object_safe_and_callable() {
206 let provider: &dyn AdDecisionProvider = &AlwaysSameAd;
207 let ctx = BreakContext {
208 session_id: "s1".into(),
209 splice_event_id: Some(7),
210 out_of_network: true,
211 break_duration_ticks: Some(1_800_000),
212 splice_point_pts: 12_345,
213 };
214 let decision = provider.decide(&ctx).unwrap();
215 assert_eq!(decision.id, "break-7");
216 assert_eq!(
217 decision.asset,
218 AssetSource::Uri("https://ads.example.com/creative.m3u8".into())
219 );
220 }
221
222 #[test]
223 fn single_asset_and_asset_list_builders() {
224 let single = AdBreakDecision::single_asset("a", "https://x/a.m3u8");
225 assert_eq!(single.asset, AssetSource::Uri("https://x/a.m3u8".into()));
226
227 let list = AdBreakDecision::asset_list("b", "https://x/list.json");
228 assert_eq!(list.asset, AssetSource::List("https://x/list.json".into()));
229 }
230
231 #[test]
232 fn label_convention() {
233 assert_eq!(SnapMode::Out.name(), "OUT");
234 assert_eq!(alloc::format!("{}", SnapMode::In), "IN");
235 assert_eq!(RestrictMode::Skip.name(), "SKIP");
236 assert_eq!(alloc::format!("{}", RestrictMode::Jump), "JUMP");
237 }
238}