arcly_stream/protocol/rtsp/
sdp.rs1#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct MediaDescription {
10 pub media: String,
12 pub payload_type: u8,
14 pub encoding: Option<String>,
16 pub clock_rate: Option<u32>,
18 pub aac_size_length: Option<u8>,
20 pub aac_index_length: Option<u8>,
22 pub control: Option<String>,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct Sdp {
29 pub media: Vec<MediaDescription>,
31}
32
33impl Sdp {
34 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 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 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 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 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 pub fn first_video_control(&self, base_url: &str) -> Option<String> {
99 self.first_control("video", base_url)
100 }
101
102 pub fn first_audio_control(&self, base_url: &str) -> Option<String> {
104 self.first_control("audio", base_url)
105 }
106
107 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 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 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 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}