Skip to main content

arcly_stream/protocol/rtsp/
sdp.rs

1//! Minimal SDP parsing for RTSP `DESCRIBE` responses (RFC 4566 / RFC 2326).
2//!
3//! Only the fields ingest needs are decoded: the media lines (`m=`), their RTP
4//! payload types, the `a=rtpmap` codec bindings, and the `a=control` track URLs
5//! used to address `SETUP`.
6
7/// A parsed media description (`m=` line plus its attributes).
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct MediaDescription {
10    /// Media kind: `"video"`, `"audio"`, …
11    pub media: String,
12    /// The first RTP payload type listed on the `m=` line.
13    pub payload_type: u8,
14    /// Encoding name from `a=rtpmap` (e.g. `"H264"`), if present.
15    pub encoding: Option<String>,
16    /// RTP clock rate from `a=rtpmap` (90000 for H.264, the sample rate for AAC).
17    pub clock_rate: Option<u32>,
18    /// `a=fmtp` AAC-hbr `sizelength` (bits of the AU-header size field), if given.
19    pub aac_size_length: Option<u8>,
20    /// `a=fmtp` AAC-hbr `indexlength` (bits of the AU-header index field), if given.
21    pub aac_index_length: Option<u8>,
22    /// `a=control` value used to build the `SETUP` URL.
23    pub control: Option<String>,
24}
25
26/// A parsed SDP session description (only the media plane is retained).
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct Sdp {
29    /// One entry per `m=` media line, in document order.
30    pub media: Vec<MediaDescription>,
31}
32
33impl Sdp {
34    /// Parse SDP text. Unknown lines are ignored; malformed media lines are
35    /// skipped rather than failing the whole description.
36    pub fn parse(text: &str) -> Sdp {
37        let mut media: Vec<MediaDescription> = Vec::new();
38        for line in text.lines() {
39            let line = line.trim_end();
40            if let Some(rest) = line.strip_prefix("m=") {
41                // "video 0 RTP/AVP 96"
42                let mut parts = rest.split(' ');
43                let kind = parts.next().unwrap_or("").to_string();
44                let pt = parts.nth(2).and_then(|p| p.parse().ok()).unwrap_or(0);
45                media.push(MediaDescription {
46                    media: kind,
47                    payload_type: pt,
48                    encoding: None,
49                    clock_rate: None,
50                    aac_size_length: None,
51                    aac_index_length: None,
52                    control: None,
53                });
54            } else if let Some(rest) = line.strip_prefix("a=") {
55                let Some(current) = media.last_mut() else {
56                    continue;
57                };
58                if let Some(rtpmap) = rest.strip_prefix("rtpmap:") {
59                    // "96 H264/90000" or "97 MPEG4-GENERIC/48000/2"
60                    if let Some((_, enc)) = rtpmap.split_once(' ') {
61                        let mut fields = enc.split('/');
62                        current.encoding = fields.next().map(|s| s.to_string());
63                        current.clock_rate = fields.next().and_then(|s| s.parse().ok());
64                    }
65                } else if let Some(fmtp) = rest.strip_prefix("fmtp:") {
66                    // "97 streamtype=5;mode=AAC-hbr;sizelength=13;indexlength=3;..."
67                    for param in fmtp.split([';', ' ']) {
68                        if let Some(v) = param.trim().strip_prefix("sizelength=") {
69                            current.aac_size_length = v.parse().ok();
70                        } else if let Some(v) = param.trim().strip_prefix("indexlength=") {
71                            current.aac_index_length = v.parse().ok();
72                        }
73                    }
74                } else if let Some(control) = rest.strip_prefix("control:") {
75                    current.control = Some(control.to_string());
76                }
77            }
78        }
79        Sdp { media }
80    }
81
82    /// Resolve the `SETUP` URL for the first track of `kind` (`"video"` /
83    /// `"audio"`), joining its `a=control` value against `base_url` (absolute
84    /// control URLs win).
85    pub fn first_control(&self, kind: &str, base_url: &str) -> Option<String> {
86        let media = self.media.iter().find(|m| m.media == kind)?;
87        let control = media.control.as_deref().unwrap_or("");
88        if control.is_empty() || control == "*" {
89            Some(base_url.to_string())
90        } else if control.starts_with("rtsp://") {
91            Some(control.to_string())
92        } else {
93            Some(format!("{}/{}", base_url.trim_end_matches('/'), control))
94        }
95    }
96
97    /// Resolve the `SETUP` URL for the first video track.
98    pub fn first_video_control(&self, base_url: &str) -> Option<String> {
99        self.first_control("video", base_url)
100    }
101
102    /// Resolve the `SETUP` URL for the first audio track, if the session has one.
103    pub fn first_audio_control(&self, base_url: &str) -> Option<String> {
104        self.first_control("audio", base_url)
105    }
106
107    /// The AAC-hbr `(sizelength, indexlength)` from the audio track's `a=fmtp`,
108    /// falling back to the RFC-3640 AAC-hbr defaults `(13, 3)` when unspecified.
109    pub fn audio_aac_lengths(&self) -> (u8, u8) {
110        let audio = self.media.iter().find(|m| m.media == "audio");
111        let size = audio.and_then(|m| m.aac_size_length).unwrap_or(13);
112        let index = audio.and_then(|m| m.aac_index_length).unwrap_or(3);
113        (size, index)
114    }
115
116    /// Whether the session advertises an AAC audio track (`MPEG4-GENERIC`).
117    pub fn has_aac_audio(&self) -> bool {
118        self.media.iter().any(|m| {
119            m.media == "audio"
120                && m.encoding
121                    .as_deref()
122                    .is_some_and(|e| e.eq_ignore_ascii_case("MPEG4-GENERIC"))
123        })
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    const SAMPLE: &str = "v=0\r\n\
132o=- 0 0 IN IP4 127.0.0.1\r\n\
133s=Session\r\n\
134m=video 0 RTP/AVP 96\r\n\
135a=rtpmap:96 H264/90000\r\n\
136a=control:trackID=1\r\n\
137m=audio 0 RTP/AVP 97\r\n\
138a=rtpmap:97 MPEG4-GENERIC/48000/2\r\n\
139a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3\r\n\
140a=control:trackID=2\r\n";
141
142    #[test]
143    fn parses_both_media_tracks() {
144        let sdp = Sdp::parse(SAMPLE);
145        assert_eq!(sdp.media.len(), 2);
146        let v = &sdp.media[0];
147        assert_eq!(v.media, "video");
148        assert_eq!(v.payload_type, 96);
149        assert_eq!(v.encoding.as_deref(), Some("H264"));
150        assert_eq!(v.control.as_deref(), Some("trackID=1"));
151        assert_eq!(sdp.media[1].encoding.as_deref(), Some("MPEG4-GENERIC"));
152    }
153
154    #[test]
155    fn parses_aac_fmtp_lengths_and_falls_back_to_defaults() {
156        let sdp = Sdp::parse(SAMPLE);
157        assert_eq!(sdp.media[1].aac_size_length, Some(13));
158        assert_eq!(sdp.media[1].aac_index_length, Some(3));
159        assert_eq!(sdp.audio_aac_lengths(), (13, 3));
160
161        // Without an fmtp, the AAC-hbr defaults apply.
162        let no_fmtp = Sdp::parse("m=audio 0 RTP/AVP 97\r\na=rtpmap:97 MPEG4-GENERIC/44100\r\n");
163        assert_eq!(no_fmtp.audio_aac_lengths(), (13, 3));
164
165        // Non-default widths are honored.
166        let custom = Sdp::parse(
167            "m=audio 0 RTP/AVP 97\r\na=fmtp:97 mode=AAC-hbr;sizelength=6;indexlength=2\r\n",
168        );
169        assert_eq!(custom.audio_aac_lengths(), (6, 2));
170    }
171
172    #[test]
173    fn builds_relative_control_url() {
174        let sdp = Sdp::parse(SAMPLE);
175        assert_eq!(
176            sdp.first_video_control("rtsp://cam/stream"),
177            Some("rtsp://cam/stream/trackID=1".to_string())
178        );
179    }
180
181    #[test]
182    fn absolute_control_url_overrides_base() {
183        let sdp = Sdp::parse("m=video 0 RTP/AVP 96\r\na=control:rtsp://cam/abs\r\n");
184        assert_eq!(
185            sdp.first_video_control("rtsp://cam/stream"),
186            Some("rtsp://cam/abs".to_string())
187        );
188    }
189
190    #[test]
191    fn empty_sdp_yields_no_media() {
192        assert!(Sdp::parse("v=0\r\n").media.is_empty());
193    }
194
195    #[test]
196    fn resolves_audio_control_and_detects_aac() {
197        let sdp = Sdp::parse(SAMPLE);
198        assert!(sdp.has_aac_audio());
199        assert_eq!(
200            sdp.first_audio_control("rtsp://cam/stream"),
201            Some("rtsp://cam/stream/trackID=2".to_string())
202        );
203    }
204}