Skip to main content

timed_metadata/
daterange.rs

1//! HLS `EXT-X-DATERANGE` model + (de)serialization.
2//!
3//! RFC 8216 / draft-pantos-hls-rfc8216bis ยง4.4.5.1. The `SCTE35-OUT`/`IN`/`CMD`
4//! attribute value is the entire `splice_info_section`, hex-encoded with a `0x`
5//! prefix.
6use crate::error::{Error, Result};
7use alloc::{
8    format,
9    string::{String, ToString},
10    vec::Vec,
11};
12
13/// Which SCTE-35 attribute carries the splice on a DATERANGE.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16#[non_exhaustive]
17pub enum Scte35Cue {
18    /// `SCTE35-OUT` โ€” start of break.
19    Out,
20    /// `SCTE35-IN` โ€” return from break.
21    In,
22    /// `SCTE35-CMD` โ€” other splice command.
23    Cmd,
24}
25
26impl Scte35Cue {
27    /// Stable label.
28    pub fn name(&self) -> &'static str {
29        match self {
30            Scte35Cue::Out => "out",
31            Scte35Cue::In => "in",
32            Scte35Cue::Cmd => "cmd",
33        }
34    }
35    fn attr_key(&self) -> &'static str {
36        match self {
37            Scte35Cue::Out => "SCTE35-OUT",
38            Scte35Cue::In => "SCTE35-IN",
39            Scte35Cue::Cmd => "SCTE35-CMD",
40        }
41    }
42}
43dvb_common::impl_spec_display!(Scte35Cue);
44
45/// A SCTE-35 attribute on a DATERANGE: the cue kind plus the raw splice bytes.
46#[derive(Debug, Clone, PartialEq, Eq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct Scte35Attr {
49    /// OUT / IN / CMD.
50    pub cue: Scte35Cue,
51    /// The verbatim `splice_info_section` bytes (emitted as `0x`-prefixed hex).
52    pub raw: Vec<u8>,
53}
54
55/// An `EXT-X-DATERANGE` tag.
56#[derive(Debug, Clone, PartialEq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub struct DateRange {
59    /// `ID` (quoted).
60    pub id: String,
61    /// `START-DATE` (quoted, ISO-8601/RFC3339).
62    pub start_date: String,
63    /// `CLASS` (quoted), if present.
64    pub class: Option<String>,
65    /// `DURATION` in seconds.
66    pub duration: Option<f64>,
67    /// `PLANNED-DURATION` in seconds.
68    pub planned_duration: Option<f64>,
69    /// SCTE-35 attribute, if present.
70    pub scte35: Option<Scte35Attr>,
71}
72
73// DateRange carries f64 fields, so it is `PartialEq` only (no `Eq`). Tests
74// compare values the crate produced, so equality is deterministic in practice.
75
76const TAG: &str = "#EXT-X-DATERANGE:";
77
78impl DateRange {
79    /// Serialize to a single `#EXT-X-DATERANGE:` line. Attribute order is fixed
80    /// (ID, START-DATE, CLASS, DURATION, PLANNED-DURATION, SCTE35-*) so that
81    /// `parse_tag_line` round-trips byte-identically.
82    pub fn to_tag_line(&self) -> String {
83        let mut out = String::from(TAG);
84        out.push_str(&format!("ID=\"{}\"", self.id));
85        out.push_str(&format!(",START-DATE=\"{}\"", self.start_date));
86        if let Some(c) = &self.class {
87            out.push_str(&format!(",CLASS=\"{}\"", c));
88        }
89        if let Some(d) = self.duration {
90            out.push_str(&format!(",DURATION={}", fmt_f64(d)));
91        }
92        if let Some(d) = self.planned_duration {
93            out.push_str(&format!(",PLANNED-DURATION={}", fmt_f64(d)));
94        }
95        if let Some(s) = &self.scte35 {
96            out.push_str(&format!(",{}=0x{}", s.cue.attr_key(), to_hex_upper(&s.raw)));
97        }
98        out
99    }
100
101    /// Parse one `#EXT-X-DATERANGE:` line.
102    pub fn parse_tag_line(s: &str) -> Result<DateRange> {
103        let body = s
104            .strip_prefix(TAG)
105            .ok_or_else(|| Error::AttrParse("missing #EXT-X-DATERANGE: prefix".to_string()))?;
106        let mut dr = DateRange {
107            id: String::new(),
108            start_date: String::new(),
109            class: None,
110            duration: None,
111            planned_duration: None,
112            scte35: None,
113        };
114        let mut seen_id = false;
115        for (k, v) in split_attrs(body) {
116            match k {
117                "ID" => {
118                    dr.id = unquote(v);
119                    seen_id = true;
120                }
121                "START-DATE" => dr.start_date = unquote(v),
122                "CLASS" => dr.class = Some(unquote(v)),
123                "DURATION" => dr.duration = Some(parse_f64(v)?),
124                "PLANNED-DURATION" => dr.planned_duration = Some(parse_f64(v)?),
125                "SCTE35-OUT" => {
126                    dr.scte35 = Some(Scte35Attr {
127                        cue: Scte35Cue::Out,
128                        raw: parse_hex(v)?,
129                    })
130                }
131                "SCTE35-IN" => {
132                    dr.scte35 = Some(Scte35Attr {
133                        cue: Scte35Cue::In,
134                        raw: parse_hex(v)?,
135                    })
136                }
137                "SCTE35-CMD" => {
138                    dr.scte35 = Some(Scte35Attr {
139                        cue: Scte35Cue::Cmd,
140                        raw: parse_hex(v)?,
141                    })
142                }
143                _ => {} // unknown attributes ignored (spec-extensible)
144            }
145        }
146        if !seen_id {
147            return Err(Error::AttrParse("DATERANGE missing ID".to_string()));
148        }
149        Ok(dr)
150    }
151}
152
153fn fmt_f64(v: f64) -> String {
154    // Integer-valued durations render without a trailing ".0" to match common output.
155    // Avoid f64::fract() (std-only intrinsic in no_std); use cast comparison instead.
156    let trunc = v as i64;
157    if v == trunc as f64 {
158        format!("{}", trunc)
159    } else {
160        format!("{}", v)
161    }
162}
163
164fn to_hex_upper(b: &[u8]) -> String {
165    let mut s = String::with_capacity(b.len() * 2);
166    for byte in b {
167        s.push_str(&format!("{:02X}", byte));
168    }
169    s
170}
171
172fn unquote(v: &str) -> String {
173    v.trim_matches('"').to_string()
174}
175
176fn parse_f64(v: &str) -> Result<f64> {
177    v.parse::<f64>()
178        .map_err(|_| Error::AttrParse(format!("bad number: {v}")))
179}
180
181fn parse_hex(v: &str) -> Result<Vec<u8>> {
182    let h = v
183        .strip_prefix("0x")
184        .or_else(|| v.strip_prefix("0X"))
185        .unwrap_or(v);
186    if h.len() % 2 != 0 {
187        return Err(Error::AttrParse("odd-length hex".to_string()));
188    }
189    (0..h.len())
190        .step_by(2)
191        .map(|i| {
192            u8::from_str_radix(&h[i..i + 2], 16)
193                .map_err(|_| Error::AttrParse("bad hex".to_string()))
194        })
195        .collect()
196}
197
198/// Split `K=V,K=V` honouring quoted values (commas inside quotes are not separators).
199fn split_attrs(body: &str) -> Vec<(&str, &str)> {
200    let mut pairs = Vec::new();
201    let bytes = body.as_bytes();
202    let (mut start, mut in_q) = (0usize, false);
203    let mut i = 0;
204    while i <= bytes.len() {
205        let at_end = i == bytes.len();
206        let c = if at_end { b',' } else { bytes[i] };
207        match c {
208            b'"' => in_q = !in_q,
209            b',' if !in_q => {
210                let field = &body[start..i];
211                if let Some(eq) = field.find('=') {
212                    pairs.push((&field[..eq], &field[eq + 1..]));
213                }
214                start = i + 1;
215            }
216            _ => {}
217        }
218        i += 1;
219    }
220    pairs
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use alloc::{string::ToString, vec};
227
228    fn sample() -> DateRange {
229        DateRange {
230            id: "2002".to_string(),
231            start_date: "2018-10-29T10:38:00.000Z".to_string(),
232            class: None,
233            duration: None,
234            planned_duration: Some(24.0),
235            scte35: Some(Scte35Attr {
236                cue: Scte35Cue::Out,
237                raw: vec![0xFC, 0x30, 0x21],
238            }),
239        }
240    }
241
242    #[test]
243    fn tag_round_trips_byte_identical() {
244        let dr = sample();
245        let line = dr.to_tag_line();
246        assert!(line.starts_with("#EXT-X-DATERANGE:"));
247        assert!(line.contains("SCTE35-OUT=0xFC3021"));
248        let back = DateRange::parse_tag_line(&line).unwrap();
249        assert_eq!(back, dr);
250    }
251
252    #[test]
253    fn cue_labels() {
254        assert_eq!(Scte35Cue::Out.name(), "out");
255        assert_eq!(alloc::format!("{}", Scte35Cue::In), "in");
256    }
257}