arcly-stream 0.8.3

An open-extensible live-media streaming kernel: lock-free zero-copy frame fan-out, instant-start GOP cache, a pluggable multi-protocol ingestion layer (RTMP, RTSP, SRT, WHIP/WHEP shipped), and a feature-gated pure-Rust media plane (MPEG-TS/HLS/fMP4) — runtime, config, and metrics free.
Documentation
//! Trickle ICE candidate fragments (WHIP/WHEP `PATCH`).
//!
//! After the initial offer/answer, a WHIP/WHEP client trickles additional ICE
//! candidates with an HTTP `PATCH` carrying an SDP **fragment** (media-type
//! `application/trickle-ice-sdpfrag`, draft-ietf-wish-whip §4.1). The fragment is
//! a slice of SDP — an `a=ice-ufrag`/`a=ice-pwd` block and one or more
//! `a=candidate:` lines, grouped per `a=mid:`.
//!
//! This module parses that fragment into [`IceCandidate`]s; the host feeds each
//! to its transport via
//! [`DtlsSrtpTransport::add_remote_candidate`](super::DtlsSrtpTransport::add_remote_candidate).
//! End-of-candidates is signalled by an `a=end-of-candidates` line (an empty
//! candidate string).

/// One trickled ICE candidate, with the media grouping it applies to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IceCandidate {
    /// The media `mid` this candidate belongs to (the `a=mid:` in effect).
    pub mid: Option<String>,
    /// The `a=ice-ufrag` in effect for this candidate's media section.
    pub ufrag: Option<String>,
    /// The candidate's SDP attribute value — everything after `a=candidate:`
    /// (e.g. `0 1 UDP 2122252543 192.0.2.1 51000 typ host`). Empty for an
    /// end-of-candidates marker.
    pub candidate: String,
}

impl IceCandidate {
    /// Whether this is an end-of-candidates marker (no further candidates for
    /// the media section).
    pub fn is_end_of_candidates(&self) -> bool {
        self.candidate.is_empty()
    }
}

/// Parse a trickle-ICE SDP fragment into its candidates, carrying the `mid`/
/// `ufrag` context each line falls under.
pub fn parse_trickle(fragment: &str) -> Vec<IceCandidate> {
    let mut out = Vec::new();
    let mut mid: Option<String> = None;
    let mut ufrag: Option<String> = None;
    for line in fragment.lines() {
        let line = line.trim();
        if let Some(m) = line.strip_prefix("a=mid:") {
            mid = Some(m.to_string());
        } else if let Some(u) = line.strip_prefix("a=ice-ufrag:") {
            ufrag = Some(u.to_string());
        } else if let Some(c) = line.strip_prefix("a=candidate:") {
            out.push(IceCandidate {
                mid: mid.clone(),
                ufrag: ufrag.clone(),
                candidate: c.to_string(),
            });
        } else if line == "a=end-of-candidates" {
            out.push(IceCandidate {
                mid: mid.clone(),
                ufrag: ufrag.clone(),
                candidate: String::new(),
            });
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_candidates_with_mid_context() {
        let frag = "a=ice-ufrag:abcd\r\n\
a=ice-pwd:0123456789012345678901\r\n\
m=audio 9 UDP/TLS/RTP/SAVPF 0\r\n\
a=mid:0\r\n\
a=candidate:1 1 UDP 2122252543 192.0.2.1 51000 typ host\r\n\
a=candidate:2 1 UDP 1685987071 198.51.100.2 52000 typ srflx\r\n\
m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
a=mid:1\r\n\
a=candidate:3 1 UDP 2122252543 192.0.2.1 51002 typ host\r\n\
a=end-of-candidates\r\n";
        let cands = parse_trickle(frag);
        assert_eq!(cands.len(), 4);
        assert_eq!(cands[0].mid.as_deref(), Some("0"));
        assert_eq!(cands[0].ufrag.as_deref(), Some("abcd"));
        assert!(cands[0].candidate.starts_with("1 1 UDP"));
        assert_eq!(cands[2].mid.as_deref(), Some("1"));
        assert!(cands[3].is_end_of_candidates());
    }

    #[test]
    fn empty_fragment_yields_nothing() {
        assert!(parse_trickle("").is_empty());
        assert!(parse_trickle("a=ice-ufrag:x\r\n").is_empty());
    }
}