Skip to main content

dash_runtime/ingest/
mod.rs

1//! DASH-IF Live Media Ingest (Interface-1) — sans-IO core (increment P1).
2//!
3//! Request classification ([`classify`]), MPD manifest grouping extraction
4//! ([`ManifestGrouping::from_mpd`]), the §5.3 status mapping ([`IngestError::status`]),
5//! and the per-track header/segment state machine ([`IngestState`]).
6//!
7//! No sockets, no tokio, no axum: inputs are plain Rust values. Cited against
8//! `private/specs/dashif_live_media_ingest_v1.3.html` §5.2, §5.3, §6.2
9//! (items 1, 11, 18, 16 a-h, 17 a-e, 19) and ISO/IEC 23009-1 §5.3.9.4.4
10//! (template grammar) as implemented by [`crate::address::template`].
11
12use std::collections::{HashMap, HashSet};
13
14use crate::address::template::match_url;
15use crate::mpd::{Mpd, SegmentTemplate};
16use broadcast_common::impl_spec_display;
17use broadcast_common::units::MICROS_PER_SEC;
18use http::StatusCode;
19
20/// HTTP method of an ingest request (§5.2.1).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum IngestMethod {
24    Post,
25    Put,
26}
27
28impl IngestMethod {
29    pub fn name(&self) -> &'static str {
30        match self {
31            IngestMethod::Post => "POST",
32            IngestMethod::Put => "PUT",
33        }
34    }
35}
36
37impl_spec_display!(IngestMethod);
38
39/// Manifest flavour accepted on the ingest path (§6.2.2, §6.2.17).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum ManifestFormat {
43    Dash,
44    Hls,
45}
46
47impl ManifestFormat {
48    pub fn name(&self) -> &'static str {
49        match self {
50            ManifestFormat::Dash => "dash",
51            ManifestFormat::Hls => "hls",
52        }
53    }
54}
55
56impl_spec_display!(ManifestFormat);
57
58/// Addressing mode derived from the MPD's `SegmentTemplate@media`.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum Addressing {
62    Number,
63    Time,
64}
65
66impl Addressing {
67    pub fn name(&self) -> &'static str {
68        match self {
69            Addressing::Number => "number",
70            Addressing::Time => "time",
71        }
72    }
73}
74
75impl_spec_display!(Addressing);
76
77/// Classified shape of one ingest request (DESIGN §2).
78#[derive(Debug, Clone, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum ObjectKind {
81    Probe,
82    Manifest(ManifestFormat),
83    Header {
84        representation_id: String,
85    },
86    Segment {
87        representation_id: String,
88        number: Option<u64>,
89        time: Option<u64>,
90    },
91}
92
93impl ObjectKind {
94    pub fn name(&self) -> &'static str {
95        match self {
96            ObjectKind::Probe => "probe",
97            ObjectKind::Manifest(_) => "manifest",
98            ObjectKind::Header { .. } => "header",
99            ObjectKind::Segment { .. } => "segment",
100        }
101    }
102}
103
104impl_spec_display!(ObjectKind);
105
106/// Disposition of an accepted segment (§6.2.10/11).
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum SegmentDisposition {
110    Accepted,
111    Duplicate,
112    Regression,
113}
114
115impl SegmentDisposition {
116    pub fn name(&self) -> &'static str {
117        match self {
118            SegmentDisposition::Accepted => "accepted",
119            SegmentDisposition::Duplicate => "duplicate",
120            SegmentDisposition::Regression => "regression",
121        }
122    }
123}
124
125impl_spec_display!(SegmentDisposition);
126
127/// Metadata extracted from one ingest HTTP request.
128#[derive(Debug, Clone, Copy)]
129pub struct RequestMeta<'a> {
130    pub method: IngestMethod,
131    pub path: &'a str,
132    pub content_length: Option<u64>,
133    pub chunked: bool,
134    pub body_empty: bool,
135}
136
137/// Grouping extracted from a DASH MPD per §6.2.16.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct ManifestGrouping {
140    pub init_template: String,
141    pub media_template: String,
142    pub addressing: Addressing,
143    pub switching_sets: Vec<Vec<String>>,
144    /// §6.2.16e SHOULD: raw `MPD@availabilityStartTime`.
145    pub availability_start_time: Option<String>,
146    /// §6.2.16e SHOULD: `Period@start`.
147    pub period_start: Option<std::time::Duration>,
148}
149
150/// Errors raised by the ingest core.
151#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
152#[non_exhaustive]
153pub enum IngestError {
154    #[error("segment without a usable header (§5.3.5c)")]
155    SegmentWithoutHeader,
156
157    #[error("receiver-side fault (§5.3.5d)")]
158    Internal,
159
160    #[error("malformed request (§5.3.5e)")]
161    BadRequest,
162
163    #[error("no manifest received yet")]
164    NoManifest,
165
166    #[error("MPD has multiple Period elements (§6.2.16h)")]
167    MpdMultiplePeriods,
168
169    #[error("MPD SegmentTemplate {attr} template mismatch (§6.2.16a/b/c)")]
170    MpdTemplateMismatch { attr: &'static str },
171
172    #[error("MPD contains a BaseURL element (§6.2.16d)")]
173    MpdBaseUrlPresent,
174
175    #[error("MPD AdaptationSet/Representation lacks a SegmentTemplate (§6.2.16 intro)")]
176    MpdMissingSegmentTemplate,
177
178    #[error("header changed mid-session (§6.2.19)")]
179    HeaderChanged,
180
181    #[error("segment already in progress for this representation (§5.3 catch-all)")]
182    SegmentInProgress,
183}
184
185impl IngestError {
186    pub fn status(&self) -> StatusCode {
187        match self {
188            IngestError::SegmentWithoutHeader => StatusCode::PRECONDITION_FAILED,
189            IngestError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
190            IngestError::SegmentInProgress => StatusCode::CONFLICT,
191            _ => StatusCode::BAD_REQUEST,
192        }
193    }
194}
195
196impl ManifestGrouping {
197    pub fn from_mpd(bytes: &[u8]) -> Result<Self, IngestError> {
198        let mpd = Mpd::parse(bytes).map_err(|_| IngestError::BadRequest)?;
199
200        if mpd.periods.len() != 1 {
201            return Err(IngestError::MpdMultiplePeriods);
202        }
203        let period = &mpd.periods[0];
204
205        // BaseURL absent anywhere (§6.2.16d).
206        if !mpd.base_urls.is_empty()
207            || !period.base_urls.is_empty()
208            || mpd
209                .periods
210                .iter()
211                .flat_map(|p| &p.adaptation_sets)
212                .any(|a| {
213                    !a.base_urls.is_empty()
214                        || a.representations.iter().any(|r| !r.base_urls.is_empty())
215                })
216        {
217            return Err(IngestError::MpdBaseUrlPresent);
218        }
219
220        // An AdaptationSet may carry the template on itself (inherited by reps).
221        let mut templates: Vec<(String, &SegmentTemplate)> = Vec::new();
222        let mut switching_sets: Vec<Vec<String>> = Vec::new();
223        for aset in &period.adaptation_sets {
224            let mut set_ids: Vec<String> = Vec::new();
225            for rep in &aset.representations {
226                let tmpl = rep
227                    .segment
228                    .template
229                    .as_ref()
230                    .or(aset.segment.template.as_ref());
231                let tmpl = match tmpl {
232                    Some(t) => t,
233                    None => return Err(IngestError::MpdMissingSegmentTemplate),
234                };
235                templates.push((rep.id.clone(), tmpl));
236                set_ids.push(rep.id.clone());
237            }
238            if !set_ids.is_empty() {
239                switching_sets.push(set_ids);
240            }
241            // AdaptationSet with no reps but its own template: nothing to group.
242        }
243
244        if templates.is_empty() {
245            return Err(IngestError::MpdMissingSegmentTemplate);
246        }
247
248        // Cross-template identity (§6.2.16b/c).
249        let first_init = templates[0].1.initialization.clone();
250        let first_media = templates[0].1.media.clone();
251        for (_, t) in &templates {
252            if t.initialization != first_init {
253                return Err(IngestError::MpdTemplateMismatch {
254                    attr: "initialization",
255                });
256            }
257            if t.media != first_media {
258                return Err(IngestError::MpdTemplateMismatch { attr: "media" });
259            }
260        }
261
262        let init_template = first_init.unwrap_or_default();
263        let media_template = first_media.unwrap_or_default();
264
265        // §6.2.16a: init contains $RepresentationID$ exactly once.
266        if init_template.matches("$RepresentationID$").count() != 1 {
267            return Err(IngestError::MpdTemplateMismatch {
268                attr: "initialization",
269            });
270        }
271        // media contains $RepresentationID$ exactly once.
272        if media_template.matches("$RepresentationID$").count() != 1 {
273            return Err(IngestError::MpdTemplateMismatch { attr: "media" });
274        }
275        // media contains exactly one of $Number$/$Time$.
276        let number_count = count_ident(&media_template, "Number");
277        let time_count = count_ident(&media_template, "Time");
278        let has_number = number_count > 0;
279        let has_time = time_count > 0;
280        if !(has_number ^ has_time) {
281            return Err(IngestError::MpdTemplateMismatch { attr: "media" });
282        }
283        if has_number && number_count != 1 {
284            return Err(IngestError::MpdTemplateMismatch { attr: "media" });
285        }
286        if has_time && time_count != 1 {
287            return Err(IngestError::MpdTemplateMismatch { attr: "media" });
288        }
289
290        let addressing = if has_number {
291            Addressing::Number
292        } else {
293            Addressing::Time
294        };
295
296        Ok(ManifestGrouping {
297            init_template,
298            media_template,
299            addressing,
300            switching_sets,
301            availability_start_time: mpd
302                .availability_start_time_us
303                .and_then(|us| us.checked_div(MICROS_PER_SEC as i64))
304                .and_then(|s| jiff::Timestamp::from_second(s).ok())
305                .map(|t| t.to_string()),
306            period_start: period.start,
307        })
308    }
309}
310
311fn count_ident(template: &str, name: &str) -> usize {
312    // Count `$Name$` and `$Name%...d$` occurrences.
313    let mut count = 0;
314    let mut rest = template;
315    while let Some(start) = rest.find('$') {
316        let tail = &rest[start + 1..];
317        if let Some(end) = tail.find('$') {
318            let ident = &tail[..end];
319            let id_name = ident.split('%').next().unwrap_or(ident);
320            if id_name == name {
321                count += 1;
322            }
323            rest = &tail[end + 1..];
324        } else {
325            break;
326        }
327    }
328    count
329}
330
331pub fn classify(
332    req: &RequestMeta<'_>,
333    grouping: Option<&ManifestGrouping>,
334) -> Result<ObjectKind, IngestError> {
335    if req.body_empty {
336        return Ok(ObjectKind::Probe);
337    }
338    let lower = req.path.to_ascii_lowercase();
339    if lower.ends_with(".mpd") {
340        return Ok(ObjectKind::Manifest(ManifestFormat::Dash));
341    }
342    if lower.ends_with(".m3u8") {
343        return Ok(ObjectKind::Manifest(ManifestFormat::Hls));
344    }
345
346    let g = grouping.ok_or(IngestError::NoManifest)?;
347
348    let known: Vec<&str> = g
349        .switching_sets
350        .iter()
351        .flatten()
352        .map(|s| s.as_str())
353        .collect();
354
355    if let Some(m) = match_url(&g.init_template, req.path, &known) {
356        return Ok(ObjectKind::Header {
357            representation_id: m.representation_id,
358        });
359    }
360    if let Some(m) = match_url(&g.media_template, req.path, &known) {
361        return Ok(ObjectKind::Segment {
362            representation_id: m.representation_id,
363            number: m.number,
364            time: m.time,
365        });
366    }
367
368    Err(IngestError::BadRequest)
369}
370
371/// Per-track header/segment state machine.
372#[derive(Debug, Default)]
373pub struct IngestState {
374    headers: HashSet<String>,
375    last_number: HashMap<String, u64>,
376    last_time: HashMap<String, u64>,
377    last_segments: HashSet<String>,
378}
379
380impl IngestState {
381    pub fn new() -> Self {
382        Self::default()
383    }
384
385    pub fn on_header(&mut self, id: &str) {
386        self.headers.insert(id.to_string());
387        // Restart the track (§6.2.19): clear monotonic state.
388        self.last_number.remove(id);
389        self.last_time.remove(id);
390        self.last_segments.remove(id);
391    }
392
393    pub fn has_header(&self, id: &str) -> bool {
394        self.headers.contains(id)
395    }
396
397    pub fn on_segment(
398        &mut self,
399        id: &str,
400        number: Option<u64>,
401        time: Option<u64>,
402    ) -> Result<SegmentDisposition, IngestError> {
403        if !self.headers.contains(id) {
404            return Err(IngestError::SegmentWithoutHeader);
405        }
406
407        let mut regression = false;
408        let mut duplicate = true;
409        let mut has_comparable = false;
410        let mut any = false;
411
412        if let Some(n) = number {
413            any = true;
414            if let Some(ln) = self.last_number.get(id) {
415                has_comparable = true;
416                if n < *ln {
417                    regression = true;
418                }
419                if n != *ln {
420                    duplicate = false;
421                }
422            } else {
423                duplicate = false;
424            }
425        }
426        if let Some(t) = time {
427            any = true;
428            if let Some(lt) = self.last_time.get(id) {
429                has_comparable = true;
430                if t < *lt {
431                    regression = true;
432                }
433                if t != *lt {
434                    duplicate = false;
435                }
436            } else {
437                duplicate = false;
438            }
439        }
440        if !any {
441            duplicate = false;
442        }
443
444        let disposition = if regression {
445            SegmentDisposition::Regression
446        } else if has_comparable && duplicate {
447            SegmentDisposition::Duplicate
448        } else {
449            SegmentDisposition::Accepted
450        };
451
452        if let Some(n) = number {
453            self.last_number.insert(id.to_string(), n);
454        }
455        if let Some(t) = time {
456            self.last_time.insert(id.to_string(), t);
457        }
458
459        Ok(disposition)
460    }
461
462    pub fn mark_last(&mut self, id: &str) {
463        self.last_segments.insert(id.to_string());
464    }
465
466    pub fn is_last(&self, id: &str) -> bool {
467        self.last_segments.contains(id)
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn status_mapping_exhaustive() {
477        // Exhaustive match (no wildcard): adding a variant breaks compile.
478        fn expected(e: &IngestError) -> u16 {
479            match e {
480                IngestError::SegmentWithoutHeader => 412,
481                IngestError::Internal => 500,
482                IngestError::BadRequest => 400,
483                IngestError::NoManifest => 400,
484                IngestError::MpdMultiplePeriods => 400,
485                IngestError::MpdTemplateMismatch { .. } => 400,
486                IngestError::MpdBaseUrlPresent => 400,
487                IngestError::MpdMissingSegmentTemplate => 400,
488                IngestError::HeaderChanged => 400,
489                IngestError::SegmentInProgress => 409,
490            }
491        }
492        let variants = [
493            IngestError::SegmentWithoutHeader,
494            IngestError::Internal,
495            IngestError::BadRequest,
496            IngestError::NoManifest,
497            IngestError::MpdMultiplePeriods,
498            IngestError::MpdTemplateMismatch { attr: "media" },
499            IngestError::MpdBaseUrlPresent,
500            IngestError::MpdMissingSegmentTemplate,
501            IngestError::HeaderChanged,
502            IngestError::SegmentInProgress,
503        ];
504        for v in &variants {
505            assert_eq!(v.status().as_u16(), expected(v));
506        }
507    }
508}
509
510pub mod session;
511pub use session::{
512    DEFAULT_BUFFERED_BUDGET_BYTES, DEFAULT_MAX_BUFFERED_OBJECT_BYTES, DEFAULT_MAX_OBJECT_BYTES,
513    DashifIngestConfig, DashifIngestSession, IngestObject, SegmentHandle, SessionError,
514};