Skip to main content

dash_runtime/ingest/
session.rs

1//! DASH-IF Live Media Ingest (Interface-1) — sans-IO ingest session (increment P2a).
2//!
3//! Mirrors multimux's DASH pull ingest session for per-Representation
4//! init bytes + the `local_to_global` track-id remap in fixed MPD order, but
5//! driven by pushed objects (`IngestObject`) instead of a pull plan. One
6//! `Manifest` announces the presentation; `Header`s register tracks; `Segment`s
7//! demux against `header ++ segment` and emit `SessionEvent::Sample`s.
8//!
9//! Cited against `private/specs-md/dashif_live_media_ingest_v1.3.md`
10//! §6.2 items 1-3, 10, 11, 18, 19 and §5.3 items 2-3.
11//!
12//! ## Buffering rule (segments arriving before `NewProgram`)
13//!
14//! A segment whose Representation already has a header but whose presentation
15//! has not yet announced `NewProgram` (some other Representation is still
16//! missing its header) is **not** dropped and **not** emitted: its samples are
17//! buffered per-Representation (bounded at 64 samples per Representation; an
18//! overflow is an error) and flushed in MPD order immediately after
19//! `NewProgram` is queued. A segment for a Representation with no header at
20//! all returns `IngestError::SegmentWithoutHeader` (412) regardless of the
21//! presentation state. Nothing is buffered when the presentation is already
22//! live.
23
24use std::collections::{HashMap, HashSet, VecDeque};
25
26use broadcast_common::{Demand, Stage, Timestamp, Unpackage};
27use media_plane::ingress::{IngestSession, ProgramId, SessionEvent};
28use media_plane::trunk::RetentionClass;
29use transmux::TrackId;
30use transmux::media::Fmp4Demux;
31use transmux::pipeline::TrackSpec;
32
33use super::{IngestError, IngestState, ManifestGrouping, ObjectKind};
34
35use tracing::{debug, warn};
36
37const MAX_TS_READ: usize = 65_536;
38
39/// One pushed ingest object: its classified kind (from [`crate::ingest::classify`])
40/// plus the raw body bytes. The session never re-parses paths.
41#[derive(Debug)]
42pub struct IngestObject<'a> {
43    /// Classified object kind.
44    pub kind: ObjectKind,
45    /// Raw body bytes.
46    pub bytes: &'a [u8],
47}
48
49/// Error returned by [`DashifIngestSession`]'s [`Stage`] implementation.
50///
51/// Exposes the underlying [`IngestError`] (with its §5.3 HTTP status) for the
52/// P2b listener, plus a stringly-typed demux failure for everything else.
53#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum SessionError {
56    /// An ingest-protocol error carrying its §5.3 status.
57    #[error("ingest: {0}")]
58    Ingest(#[from] IngestError),
59    /// A CMAF demux failure (init or segment).
60    #[error("demux: {0}")]
61    Demux(String),
62}
63
64#[derive(Default)]
65struct RepData {
66    header_bytes: Option<Vec<u8>>,
67    local_tracks: Option<Vec<TrackSpec>>,
68    init_bytes: Option<Vec<u8>>,
69    local_to_global: Option<HashMap<TrackId, TrackId>>,
70    buffered: Vec<(TrackId, transmux::ir::Sample)>,
71}
72
73/// Handle for a streaming (chunked) segment being fed via
74/// [`DashifIngestSession::begin_segment`] / `push_segment_bytes` /
75/// `finish_segment`.
76#[derive(Debug, Clone)]
77pub struct SegmentHandle {
78    rep_id: String,
79    skip: bool,
80    buf: Vec<u8>,
81}
82
83/// Default for [`DashifIngestConfig::max_object_bytes`]: 64 MiB.
84pub const DEFAULT_MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024;
85
86/// Default for [`DashifIngestConfig::max_buffered_object_bytes`]: 8 MiB.
87pub const DEFAULT_MAX_BUFFERED_OBJECT_BYTES: usize = 8 * 1024 * 1024;
88
89/// Default for [`DashifIngestConfig::buffered_budget_bytes`]: 64 MiB.
90pub const DEFAULT_BUFFERED_BUDGET_BYTES: usize = 64 * 1024 * 1024;
91
92/// The limits a DASH-IF ingest host enforces on what a publisher sends. The
93/// session carries them so the host reads one policy from one place.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[non_exhaustive]
96pub struct DashifIngestConfig {
97    /// Hard cap on one ingested object's body.
98    pub max_object_bytes: usize,
99    /// Cap on one buffered (non-segment) object: manifests and init segments
100    /// are kilobytes and every buffered byte is held in memory until consumed,
101    /// so this is far below `max_object_bytes`, which bounds a streamed media
102    /// segment. The effective cap is the smaller of the two.
103    pub max_buffered_object_bytes: usize,
104    /// Bytes of non-segment objects held buffered at once, summed over
105    /// concurrent requests; a request that would exceed it is refused.
106    pub buffered_budget_bytes: usize,
107}
108
109impl Default for DashifIngestConfig {
110    fn default() -> Self {
111        DashifIngestConfig {
112            max_object_bytes: DEFAULT_MAX_OBJECT_BYTES,
113            max_buffered_object_bytes: DEFAULT_MAX_BUFFERED_OBJECT_BYTES,
114            buffered_budget_bytes: DEFAULT_BUFFERED_BUDGET_BYTES,
115        }
116    }
117}
118
119impl DashifIngestConfig {
120    /// Replace [`DashifIngestConfig::max_object_bytes`].
121    #[must_use]
122    pub fn with_max_object_bytes(mut self, max_object_bytes: usize) -> Self {
123        self.max_object_bytes = max_object_bytes;
124        self
125    }
126
127    /// Replace [`DashifIngestConfig::max_buffered_object_bytes`].
128    #[must_use]
129    pub fn with_max_buffered_object_bytes(mut self, max_buffered_object_bytes: usize) -> Self {
130        self.max_buffered_object_bytes = max_buffered_object_bytes;
131        self
132    }
133
134    /// Replace [`DashifIngestConfig::buffered_budget_bytes`].
135    #[must_use]
136    pub fn with_buffered_budget_bytes(mut self, buffered_budget_bytes: usize) -> Self {
137        self.buffered_budget_bytes = buffered_budget_bytes;
138        self
139    }
140}
141
142/// Sans-IO ingest session for one DASH-IF Interface-1 publishing point.
143pub struct DashifIngestSession {
144    config: DashifIngestConfig,
145    grouping: Option<ManifestGrouping>,
146    rep_order: Vec<String>,
147    reps: HashMap<String, RepData>,
148    ingest_state: IngestState,
149    established_emitted: bool,
150    new_program_emitted: bool,
151    pending_events: VecDeque<SessionEvent>,
152    active_segments: HashSet<String>,
153}
154
155impl DashifIngestSession {
156    /// The manifest grouping learned from a successful Manifest, if any.
157    pub fn grouping(&self) -> Option<ManifestGrouping> {
158        self.grouping.clone()
159    }
160
161    /// The limits this session was configured with.
162    pub fn config(&self) -> DashifIngestConfig {
163        self.config
164    }
165
166    /// Construct an empty session with `config`. No manifest has been
167    /// received yet.
168    pub fn new(config: DashifIngestConfig) -> Self {
169        Self {
170            config,
171            grouping: None,
172            rep_order: Vec::new(),
173            reps: HashMap::new(),
174            ingest_state: IngestState::new(),
175            established_emitted: false,
176            new_program_emitted: false,
177            pending_events: VecDeque::new(),
178            active_segments: HashSet::new(),
179        }
180    }
181
182    fn on_header(&mut self, rep_id: &str, bytes: &[u8]) -> Result<(), SessionError> {
183        if self.grouping.is_none() {
184            return Err(SessionError::Ingest(IngestError::NoManifest));
185        }
186        if !self.reps.contains_key(rep_id) {
187            return Err(SessionError::Ingest(IngestError::BadRequest));
188        }
189
190        // Identical repeat header: idempotent restart (§6.2.19).
191        let is_identical = self
192            .reps
193            .get(rep_id)
194            .and_then(|r| r.header_bytes.as_ref())
195            .map(|prev| prev == bytes)
196            .unwrap_or(false);
197        if is_identical {
198            debug!("dashif-ingest: identical header repeat ignored for {rep_id}");
199            self.ingest_state.on_header(rep_id);
200            return Ok(());
201        }
202
203        // Different header for an already-live Representation is a new generation.
204        if self.new_program_emitted
205            && self
206                .reps
207                .get(rep_id)
208                .is_some_and(|r| r.header_bytes.is_some())
209        {
210            return Err(SessionError::Ingest(IngestError::HeaderChanged));
211        }
212
213        // Demux init: must carry at least one track.
214        let media = Fmp4Demux::new()
215            .unpackage(bytes)
216            .map_err(|e| SessionError::Demux(e.to_string()))?;
217        if media.tracks.is_empty() {
218            return Err(SessionError::Ingest(IngestError::BadRequest));
219        }
220        let local_tracks: Vec<TrackSpec> = media.tracks.into_iter().map(|t| t.spec).collect();
221
222        let rep = self
223            .reps
224            .get_mut(rep_id)
225            .ok_or(SessionError::Ingest(IngestError::Internal))?;
226        rep.header_bytes = Some(bytes.to_vec());
227        rep.init_bytes = Some(bytes.to_vec());
228        rep.local_tracks = Some(local_tracks);
229
230        self.ingest_state.on_header(rep_id);
231
232        // Once every Representation has a header, announce the program.
233        if !self.new_program_emitted
234            && self.rep_order.iter().all(|id| {
235                self.reps
236                    .get(id)
237                    .map(|r| r.header_bytes.is_some())
238                    .unwrap_or(false)
239            })
240        {
241            self.emit_new_program();
242            self.flush_buffered();
243        }
244
245        Ok(())
246    }
247
248    fn emit_new_program(&mut self) {
249        let mut next_track_id = TrackId::new(1);
250        let mut specs = Vec::new();
251        for rep_id in self.rep_order.clone() {
252            let Some(rep) = self.reps.get_mut(&rep_id) else {
253                continue;
254            };
255            let local_tracks = rep.local_tracks.clone().unwrap_or_default();
256            let mut l2g = HashMap::new();
257            for spec in &local_tracks {
258                let global_id = next_track_id;
259                next_track_id = next_track_id.next();
260                l2g.insert(spec.track_id, global_id);
261                let mut remapped = spec.clone();
262                remapped.track_id = global_id;
263                specs.push(remapped);
264            }
265            rep.local_to_global = Some(l2g);
266        }
267        self.pending_events.push_back(SessionEvent::NewProgram {
268            program: ProgramId(0),
269            tracks: specs,
270        });
271        self.new_program_emitted = true;
272    }
273
274    fn flush_buffered(&mut self) {
275        for rep_id in self.rep_order.clone() {
276            let Some(rep) = self.reps.get_mut(&rep_id) else {
277                continue;
278            };
279            let l2g = match &rep.local_to_global {
280                Some(m) => m.clone(),
281                None => continue,
282            };
283            let buffered = std::mem::take(&mut rep.buffered);
284            for (local_id, sample) in buffered {
285                if let Some(&global_id) = l2g.get(&local_id) {
286                    self.pending_events.push_back(SessionEvent::Sample {
287                        program: ProgramId(0),
288                        track_id: global_id,
289                        retention: RetentionClass::Timed,
290                        sample,
291                    });
292                }
293            }
294        }
295    }
296
297    fn on_segment(
298        &mut self,
299        rep_id: &str,
300        number: Option<u64>,
301        time: Option<u64>,
302        bytes: &[u8],
303    ) -> Result<(), SessionError> {
304        let mut handle = self.begin_segment(rep_id, number, time)?;
305        if let Err(e) = self.push_segment_bytes(&mut handle, bytes) {
306            self.active_segments.remove(rep_id);
307            // drop handle
308            let _ = handle;
309            return Err(e);
310        }
311        self.finish_segment(handle)
312    }
313
314    pub fn begin_segment(
315        &mut self,
316        rep: impl AsRef<str>,
317        number: Option<u64>,
318        time: Option<u64>,
319    ) -> Result<SegmentHandle, SessionError> {
320        let rep_id = rep.as_ref();
321        if self.grouping.is_none() {
322            return Err(SessionError::Ingest(IngestError::NoManifest));
323        }
324        if !self.reps.contains_key(rep_id) {
325            return Err(SessionError::Ingest(IngestError::BadRequest));
326        }
327        if !self.ingest_state.has_header(rep_id) {
328            return Err(SessionError::Ingest(IngestError::SegmentWithoutHeader));
329        }
330        if self.active_segments.contains(rep_id) {
331            return Err(SessionError::Ingest(IngestError::SegmentInProgress));
332        }
333        let disp = self
334            .ingest_state
335            .on_segment(rep_id, number, time)
336            .map_err(SessionError::Ingest)?;
337
338        if disp == super::SegmentDisposition::Regression {
339            warn!("dashif-ingest: segment regression for {rep_id}");
340        }
341
342        self.active_segments.insert(rep_id.to_string());
343
344        Ok(SegmentHandle {
345            rep_id: rep_id.to_string(),
346            skip: disp == super::SegmentDisposition::Duplicate,
347            buf: Vec::new(),
348        })
349    }
350
351    pub fn push_segment_bytes(
352        &mut self,
353        handle: &mut SegmentHandle,
354        bytes: &[u8],
355    ) -> Result<(), SessionError> {
356        handle.buf.extend_from_slice(bytes);
357
358        if handle.skip {
359            // Swallow complete box groups (detect lmsg on a leading styp) but
360            // emit nothing.
361            loop {
362                let mut i = 0usize;
363                let mut end = None;
364                while i < handle.buf.len() {
365                    match transmux::parse_box(&handle.buf[i..]) {
366                        Ok((bx, consumed)) => {
367                            i += consumed;
368                            if bx.header.box_type.is(b"mdat") {
369                                end = Some(i);
370                                break;
371                            }
372                            if consumed == 0 {
373                                break;
374                            }
375                        }
376                        Err(_) => break,
377                    }
378                }
379                match end {
380                    Some(e) => {
381                        self.detect_styp_lmsg(&handle.rep_id, &handle.buf[..e]);
382                        handle.buf.drain(..e);
383                    }
384                    None => break,
385                }
386            }
387            return Ok(());
388        }
389
390        loop {
391            let mut i = 0usize;
392            let mut end = None;
393            while i < handle.buf.len() {
394                match transmux::parse_box(&handle.buf[i..]) {
395                    Ok((bx, consumed)) => {
396                        i += consumed;
397                        if bx.header.box_type.is(b"mdat") {
398                            end = Some(i);
399                            break;
400                        }
401                        if consumed == 0 {
402                            break;
403                        }
404                    }
405                    Err(_) => break,
406                }
407            }
408            let Some(e) = end else {
409                break;
410            };
411
412            let group = handle.buf[..e].to_vec();
413            self.detect_styp_lmsg(&handle.rep_id, &group);
414
415            // Demux init ++ chunk.
416            let init = self
417                .reps
418                .get(&handle.rep_id)
419                .and_then(|r| r.init_bytes.clone())
420                .ok_or(SessionError::Ingest(IngestError::SegmentWithoutHeader))?;
421            let mut combined = Vec::with_capacity(init.len() + group.len());
422            combined.extend_from_slice(&init);
423            combined.extend_from_slice(&group);
424            let media = Fmp4Demux::new()
425                .unpackage(&combined)
426                .map_err(|e| SessionError::Demux(e.to_string()))?;
427
428            if self.new_program_emitted {
429                let l2g = self
430                    .reps
431                    .get(&handle.rep_id)
432                    .and_then(|r| r.local_to_global.clone())
433                    .unwrap_or_default();
434                for track in media.tracks {
435                    if let Some(&global_id) = l2g.get(&track.spec.track_id) {
436                        for sample in track.samples {
437                            self.pending_events.push_back(SessionEvent::Sample {
438                                program: ProgramId(0),
439                                track_id: global_id,
440                                retention: RetentionClass::Timed,
441                                sample,
442                            });
443                        }
444                    }
445                }
446            } else {
447                let new_count: usize = media.tracks.iter().map(|t| t.samples.len()).sum();
448                let rep = self
449                    .reps
450                    .get_mut(&handle.rep_id)
451                    .ok_or(SessionError::Ingest(IngestError::Internal))?;
452                if rep.buffered.len() + new_count > 64 {
453                    return Err(SessionError::Ingest(IngestError::Internal));
454                }
455                for track in media.tracks {
456                    for sample in track.samples {
457                        rep.buffered.push((track.spec.track_id, sample));
458                    }
459                }
460            }
461
462            handle.buf.drain(..e);
463        }
464
465        Ok(())
466    }
467
468    pub fn finish_segment(&mut self, mut handle: SegmentHandle) -> Result<(), SessionError> {
469        let push_res = self.push_segment_bytes(&mut handle, &[]);
470        let rep_id = handle.rep_id.clone();
471        if let Err(e) = push_res {
472            self.active_segments.remove(&rep_id);
473            return Err(e);
474        }
475
476        if handle.skip {
477            self.active_segments.remove(&rep_id);
478            return Ok(());
479        }
480
481        if !handle.buf.is_empty() {
482            self.active_segments.remove(&rep_id);
483            return Err(SessionError::Ingest(IngestError::BadRequest));
484        }
485        self.active_segments.remove(&rep_id);
486        Ok(())
487    }
488
489    /// Discard a partially-streamed segment (P3b): drop any buffered remainder
490    /// without emitting it. Chunks already published stay published.
491    pub fn abort_segment(&mut self, handle: SegmentHandle) {
492        self.active_segments.remove(&handle.rep_id);
493        // handle dropped here
494    }
495
496    fn detect_styp_lmsg(&mut self, rep_id: &str, bytes: &[u8]) {
497        if let Ok((boxref, _)) = transmux::parse_box(bytes)
498            && boxref.header.box_type.is(b"styp")
499        {
500            let body = boxref.body;
501            if body.len() >= 8 {
502                let mut i = 8usize;
503                let mut slat_count = 0usize;
504                while i + 4 <= body.len() {
505                    let brand = &body[i..i + 4];
506                    if brand == b"lmsg" {
507                        self.ingest_state.mark_last(rep_id);
508                    } else if brand == b"slat" {
509                        slat_count += 1;
510                    }
511                    i += 4;
512                }
513                if slat_count > 0 {
514                    debug!("dashif-ingest: slat brand count {slat_count} for {rep_id}");
515                }
516            }
517        }
518    }
519}
520
521impl Stage for DashifIngestSession {
522    type In<'a> = IngestObject<'a>;
523    type Out = SessionEvent;
524    type Error = SessionError;
525
526    fn feed(&mut self, input: Self::In<'_>, _now: Timestamp) -> Result<(), Self::Error> {
527        match input.kind {
528            ObjectKind::Probe => Ok(()),
529            ObjectKind::Manifest(super::ManifestFormat::Dash) => {
530                if self.grouping.is_some() {
531                    debug!("dashif-ingest: re-sent manifest ignored");
532                    return Ok(());
533                }
534                let g = ManifestGrouping::from_mpd(input.bytes).map_err(SessionError::Ingest)?;
535                self.rep_order = g.switching_sets.iter().flatten().cloned().collect();
536                for id in &self.rep_order {
537                    self.reps.entry(id.clone()).or_default();
538                }
539                self.grouping = Some(g);
540                if !self.established_emitted {
541                    self.pending_events.push_back(SessionEvent::Established);
542                    self.established_emitted = true;
543                }
544                Ok(())
545            }
546            ObjectKind::Manifest(super::ManifestFormat::Hls) => {
547                debug!("dashif-ingest: HLS manifest ignored (§6.2.17e)");
548                Ok(())
549            }
550            ObjectKind::Header { representation_id } => {
551                self.on_header(&representation_id, input.bytes)
552            }
553            ObjectKind::Segment {
554                representation_id,
555                number,
556                time,
557            } => self.on_segment(&representation_id, number, time, input.bytes),
558        }
559    }
560
561    fn poll(&mut self) -> Option<Self::Out> {
562        self.pending_events.pop_front()
563    }
564
565    fn finish(&mut self) -> Result<(), Self::Error> {
566        Ok(())
567    }
568
569    fn demand(&self) -> Demand {
570        Demand::new(MAX_TS_READ)
571    }
572}
573
574impl IngestSession for DashifIngestSession {
575    type Request = std::convert::Infallible;
576
577    fn poll_transmit(&mut self) -> Option<Self::Request> {
578        None
579    }
580}
581
582// end of file