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    /// Base64-encoded out-of-band parameter-set NALs from `a=fmtp`, in decode
25    /// order: H.264 `sprop-parameter-sets=<sps>,<pps>`; H.265 `sprop-vps`,
26    /// `sprop-sps`, `sprop-pps`. Empty when the encoder repeats them in-band.
27    pub sprop_sets: Vec<String>,
28}
29
30/// A parsed SDP session description (only the media plane is retained).
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct Sdp {
33    /// One entry per `m=` media line, in document order.
34    pub media: Vec<MediaDescription>,
35}
36
37impl Sdp {
38    /// Parse SDP text. Unknown lines are ignored; malformed media lines are
39    /// skipped rather than failing the whole description.
40    pub fn parse(text: &str) -> Sdp {
41        let mut media: Vec<MediaDescription> = Vec::new();
42        for line in text.lines() {
43            let line = line.trim_end();
44            if let Some(rest) = line.strip_prefix("m=") {
45                // "video 0 RTP/AVP 96"
46                let mut parts = rest.split(' ');
47                let kind = parts.next().unwrap_or("").to_string();
48                let pt = parts.nth(2).and_then(|p| p.parse().ok()).unwrap_or(0);
49                media.push(MediaDescription {
50                    media: kind,
51                    payload_type: pt,
52                    encoding: None,
53                    clock_rate: None,
54                    aac_size_length: None,
55                    aac_index_length: None,
56                    control: None,
57                    sprop_sets: Vec::new(),
58                });
59            } else if let Some(rest) = line.strip_prefix("a=") {
60                let Some(current) = media.last_mut() else {
61                    continue;
62                };
63                if let Some(rtpmap) = rest.strip_prefix("rtpmap:") {
64                    // "96 H264/90000" or "97 MPEG4-GENERIC/48000/2"
65                    if let Some((_, enc)) = rtpmap.split_once(' ') {
66                        let mut fields = enc.split('/');
67                        current.encoding = fields.next().map(|s| s.to_string());
68                        current.clock_rate = fields.next().and_then(|s| s.parse().ok());
69                    }
70                } else if let Some(fmtp) = rest.strip_prefix("fmtp:") {
71                    // "97 streamtype=5;mode=AAC-hbr;sizelength=13;indexlength=3;..."
72                    for param in fmtp.split([';', ' ']) {
73                        let param = param.trim();
74                        if let Some(v) = param.strip_prefix("sizelength=") {
75                            current.aac_size_length = v.parse().ok();
76                        } else if let Some(v) = param.strip_prefix("indexlength=") {
77                            current.aac_index_length = v.parse().ok();
78                        } else if let Some(v) = param.strip_prefix("sprop-parameter-sets=") {
79                            // H.264: comma-separated base64 SPS,PPS (decode order).
80                            current
81                                .sprop_sets
82                                .extend(v.split(',').filter(|s| !s.is_empty()).map(String::from));
83                        } else if let Some(v) = param
84                            .strip_prefix("sprop-vps=")
85                            .or_else(|| param.strip_prefix("sprop-sps="))
86                            .or_else(|| param.strip_prefix("sprop-pps="))
87                        {
88                            // H.265: one base64 NAL per key; SDP lists VPS, SPS, PPS.
89                            if !v.is_empty() {
90                                current.sprop_sets.push(v.to_string());
91                            }
92                        }
93                    }
94                } else if let Some(control) = rest.strip_prefix("control:") {
95                    current.control = Some(control.to_string());
96                }
97            }
98        }
99        Sdp { media }
100    }
101
102    /// Resolve the `SETUP` URL for the first track of `kind` (`"video"` /
103    /// `"audio"`), joining its `a=control` value against `base_url` (absolute
104    /// control URLs win).
105    pub fn first_control(&self, kind: &str, base_url: &str) -> Option<String> {
106        let media = self.media.iter().find(|m| m.media == kind)?;
107        let control = media.control.as_deref().unwrap_or("");
108        if control.is_empty() || control == "*" {
109            Some(base_url.to_string())
110        } else if control.starts_with("rtsp://") {
111            Some(control.to_string())
112        } else {
113            Some(format!("{}/{}", base_url.trim_end_matches('/'), control))
114        }
115    }
116
117    /// Resolve the `SETUP` URL for the first video track.
118    pub fn first_video_control(&self, base_url: &str) -> Option<String> {
119        self.first_control("video", base_url)
120    }
121
122    /// Resolve the `SETUP` URL for the first audio track, if the session has one.
123    pub fn first_audio_control(&self, base_url: &str) -> Option<String> {
124        self.first_control("audio", base_url)
125    }
126
127    /// The AAC-hbr `(sizelength, indexlength)` from the audio track's `a=fmtp`,
128    /// falling back to the RFC-3640 AAC-hbr defaults `(13, 3)` when unspecified.
129    pub fn audio_aac_lengths(&self) -> (u8, u8) {
130        let audio = self.media.iter().find(|m| m.media == "audio");
131        let size = audio.and_then(|m| m.aac_size_length).unwrap_or(13);
132        let index = audio.and_then(|m| m.aac_index_length).unwrap_or(3);
133        (size, index)
134    }
135
136    /// The video track's out-of-band parameter sets (from `sprop-*`) as an
137    /// Annex-B bytestream (`00 00 00 01` start codes), or `None` when the SDP
138    /// carried none. This is the CONFIG access unit a fMP4/TS packager needs to
139    /// build its init segment — encoders like ffmpeg's RTP muxer advertise the
140    /// parameter sets here rather than repeating them in-band before each IDR.
141    pub fn video_config_annexb(&self) -> Option<bytes::Bytes> {
142        let video = self.media.iter().find(|m| m.media == "video")?;
143        let mut out = Vec::new();
144        for b64 in &video.sprop_sets {
145            let nal = super::base64::decode(b64)?;
146            if nal.is_empty() {
147                continue;
148            }
149            out.extend_from_slice(&[0, 0, 0, 1]);
150            out.extend_from_slice(&nal);
151        }
152        (!out.is_empty()).then(|| bytes::Bytes::from(out))
153    }
154
155    /// Whether the session advertises an AAC audio track (`MPEG4-GENERIC`).
156    pub fn has_aac_audio(&self) -> bool {
157        self.media.iter().any(|m| {
158            m.media == "audio"
159                && m.encoding
160                    .as_deref()
161                    .is_some_and(|e| e.eq_ignore_ascii_case("MPEG4-GENERIC"))
162        })
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    const SAMPLE: &str = "v=0\r\n\
171o=- 0 0 IN IP4 127.0.0.1\r\n\
172s=Session\r\n\
173m=video 0 RTP/AVP 96\r\n\
174a=rtpmap:96 H264/90000\r\n\
175a=control:trackID=1\r\n\
176m=audio 0 RTP/AVP 97\r\n\
177a=rtpmap:97 MPEG4-GENERIC/48000/2\r\n\
178a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3\r\n\
179a=control:trackID=2\r\n";
180
181    #[test]
182    fn parses_both_media_tracks() {
183        let sdp = Sdp::parse(SAMPLE);
184        assert_eq!(sdp.media.len(), 2);
185        let v = &sdp.media[0];
186        assert_eq!(v.media, "video");
187        assert_eq!(v.payload_type, 96);
188        assert_eq!(v.encoding.as_deref(), Some("H264"));
189        assert_eq!(v.control.as_deref(), Some("trackID=1"));
190        assert_eq!(sdp.media[1].encoding.as_deref(), Some("MPEG4-GENERIC"));
191    }
192
193    #[test]
194    fn parses_aac_fmtp_lengths_and_falls_back_to_defaults() {
195        let sdp = Sdp::parse(SAMPLE);
196        assert_eq!(sdp.media[1].aac_size_length, Some(13));
197        assert_eq!(sdp.media[1].aac_index_length, Some(3));
198        assert_eq!(sdp.audio_aac_lengths(), (13, 3));
199
200        // Without an fmtp, the AAC-hbr defaults apply.
201        let no_fmtp = Sdp::parse("m=audio 0 RTP/AVP 97\r\na=rtpmap:97 MPEG4-GENERIC/44100\r\n");
202        assert_eq!(no_fmtp.audio_aac_lengths(), (13, 3));
203
204        // Non-default widths are honored.
205        let custom = Sdp::parse(
206            "m=audio 0 RTP/AVP 97\r\na=fmtp:97 mode=AAC-hbr;sizelength=6;indexlength=2\r\n",
207        );
208        assert_eq!(custom.audio_aac_lengths(), (6, 2));
209    }
210
211    #[test]
212    fn builds_relative_control_url() {
213        let sdp = Sdp::parse(SAMPLE);
214        assert_eq!(
215            sdp.first_video_control("rtsp://cam/stream"),
216            Some("rtsp://cam/stream/trackID=1".to_string())
217        );
218    }
219
220    #[test]
221    fn absolute_control_url_overrides_base() {
222        let sdp = Sdp::parse("m=video 0 RTP/AVP 96\r\na=control:rtsp://cam/abs\r\n");
223        assert_eq!(
224            sdp.first_video_control("rtsp://cam/stream"),
225            Some("rtsp://cam/abs".to_string())
226        );
227    }
228
229    #[test]
230    fn empty_sdp_yields_no_media() {
231        assert!(Sdp::parse("v=0\r\n").media.is_empty());
232    }
233
234    #[test]
235    fn resolves_audio_control_and_detects_aac() {
236        let sdp = Sdp::parse(SAMPLE);
237        assert!(sdp.has_aac_audio());
238        assert_eq!(
239            sdp.first_audio_control("rtsp://cam/stream"),
240            Some("rtsp://cam/stream/trackID=2".to_string())
241        );
242    }
243}
244
245#[cfg(test)]
246mod ffmpeg_sprop_tests {
247    use super::*;
248    #[test]
249    fn extracts_ffmpeg_h264_sprop() {
250        let sdp = Sdp::parse("m=video 0 RTP/AVP 96\r\na=rtpmap:96 H264/90000\r\na=fmtp:96 packetization-mode=1; sprop-parameter-sets=Z0LADNoFB+wEQAAAAwBAAAAHg8UKqA==,aM4PyA==; profile-level-id=42C00C\r\n");
251        let cfg = sdp.video_config_annexb().expect("config present");
252        // Two NALs, each prefixed with 00 00 00 01; first is SPS (type 7).
253        assert_eq!(&cfg[0..4], &[0, 0, 0, 1]);
254        assert_eq!(cfg[4] & 0x1f, 7, "first NAL is SPS");
255        assert!(
256            cfg.windows(4).filter(|w| *w == [0, 0, 0, 1]).count() >= 2,
257            "two start codes"
258        );
259    }
260}