arcly_stream/protocol/webrtc/
sdp.rs1#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct SdpOffer {
11 pub mid: String,
13 pub payload_type: u8,
15 pub audio_payload_type: Option<u8>,
18 pub send: bool,
20 pub rid_ext_id: Option<u8>,
24 pub simulcast_rids: Vec<String>,
28 pub data_mid: Option<String>,
31}
32
33impl SdpOffer {
34 pub fn has_data_channel(&self) -> bool {
36 self.data_mid.is_some()
37 }
38
39 pub fn is_simulcast(&self) -> bool {
41 self.simulcast_rids.len() > 1
42 }
43
44 pub fn parse(sdp: &str) -> Option<SdpOffer> {
52 #[derive(PartialEq)]
53 enum Section {
54 None,
55 Video,
56 Audio,
57 Application,
58 }
59 let mut section = Section::None;
60 let mut payload_type = None;
61 let mut mid = "0".to_string();
62 let mut send = false;
63 let mut h264_pt = None;
64 let mut opus_pt = None;
65 let mut audio_default_pt = None;
66 let mut rid_ext_id = None;
67 let mut rid_send: Vec<String> = Vec::new();
68 let mut simulcast: Vec<String> = Vec::new();
69 let mut data_mid = None;
70 let mut app_mid = "data".to_string();
71
72 for line in sdp.lines() {
73 let line = line.trim_end();
74 if let Some(rest) = line.strip_prefix("m=") {
75 if rest.starts_with("video") {
76 section = Section::Video;
77 payload_type = rest.split(' ').nth(3).and_then(|p| p.parse().ok());
79 } else if rest.starts_with("audio") {
80 section = Section::Audio;
81 audio_default_pt = rest.split(' ').nth(3).and_then(|p| p.parse().ok());
82 } else if rest.starts_with("application") {
83 section = Section::Application;
84 data_mid = Some(app_mid.clone());
85 } else {
86 section = Section::None;
87 }
88 } else if section == Section::Video {
89 if let Some(rtpmap) = line.strip_prefix("a=rtpmap:") {
90 if rtpmap.contains("H264") && h264_pt.is_none() {
97 h264_pt = rtpmap.split(' ').next().and_then(|p| p.parse().ok());
98 }
99 } else if let Some(m) = line.strip_prefix("a=mid:") {
100 mid = m.to_string();
101 } else if let Some(ext) = line.strip_prefix("a=extmap:") {
102 if ext.contains("sdes:rid") && !ext.contains("repaired") {
104 rid_ext_id = ext.split(['/', ' ']).next().and_then(|id| id.parse().ok());
105 }
106 } else if let Some(rid) = line.strip_prefix("a=rid:") {
107 let mut it = rid.split_whitespace();
109 if let (Some(id), Some(dir)) = (it.next(), it.next()) {
110 if dir == "send" {
111 rid_send.push(id.to_string());
112 }
113 }
114 } else if let Some(sc) = line.strip_prefix("a=simulcast:") {
115 if let Some(send_list) = sc.strip_prefix("send ") {
118 simulcast = send_list
119 .split(';')
120 .filter_map(|alt| alt.split(',').next())
121 .map(|r| r.trim_start_matches('~').to_string())
122 .filter(|r| !r.is_empty())
123 .collect();
124 }
125 } else if line == "a=sendonly" || line == "a=sendrecv" {
126 send = true;
127 }
128 } else if section == Section::Audio {
129 if let Some(rtpmap) = line.strip_prefix("a=rtpmap:") {
130 if rtpmap.to_ascii_lowercase().contains("opus") && opus_pt.is_none() {
131 opus_pt = rtpmap.split(' ').next().and_then(|p| p.parse().ok());
132 }
133 } else if line == "a=sendonly" || line == "a=sendrecv" {
134 send = true;
135 }
136 } else if section == Section::Application {
137 if let Some(m) = line.strip_prefix("a=mid:") {
138 app_mid = m.to_string();
139 data_mid = Some(app_mid.clone());
140 }
141 }
142 }
143
144 let payload_type = h264_pt.or(payload_type)?;
145 let simulcast_rids = if !simulcast.is_empty() {
147 simulcast
148 } else {
149 rid_send
150 };
151 Some(SdpOffer {
152 mid,
153 payload_type,
154 audio_payload_type: opus_pt.or(audio_default_pt),
155 send,
156 rid_ext_id,
157 simulcast_rids,
158 data_mid,
159 })
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct SdpAnswerParams {
166 pub fingerprint: String,
168 pub ice_ufrag: String,
170 pub ice_pwd: String,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum MediaDirection {
178 RecvOnly,
180 SendOnly,
182}
183
184impl MediaDirection {
185 fn attr(self) -> &'static str {
186 match self {
187 MediaDirection::RecvOnly => "recvonly",
188 MediaDirection::SendOnly => "sendonly",
189 }
190 }
191}
192
193pub fn build_answer(offer: &SdpOffer, params: &SdpAnswerParams) -> String {
195 build_answer_directed(offer, params, MediaDirection::RecvOnly)
196}
197
198pub fn build_answer_directed(
204 offer: &SdpOffer,
205 params: &SdpAnswerParams,
206 direction: MediaDirection,
207) -> String {
208 let pt = offer.payload_type;
209 let bundle = match &offer.data_mid {
211 Some(d) => format!("{} {}", offer.mid, d),
212 None => offer.mid.clone(),
213 };
214 let mut sdp = format!(
215 "v=0\r\n\
216o=- 0 0 IN IP4 0.0.0.0\r\n\
217s=-\r\n\
218t=0 0\r\n\
219a=group:BUNDLE {bundle}\r\n\
220m=video 9 UDP/TLS/RTP/SAVPF {pt}\r\n\
221c=IN IP4 0.0.0.0\r\n\
222a=rtcp-mux\r\n\
223a=mid:{mid}\r\n\
224a={dir}\r\n\
225a=ice-ufrag:{ufrag}\r\n\
226a=ice-pwd:{pwd}\r\n\
227a=fingerprint:{fp}\r\n\
228a=setup:passive\r\n\
229a=rtpmap:{pt} H264/90000\r\n\
230a=rtcp-fb:{pt} nack pli\r\n\
231a=rtcp-fb:{pt} ccm fir\r\n",
232 bundle = bundle,
233 mid = offer.mid,
234 pt = pt,
235 dir = direction.attr(),
236 ufrag = params.ice_ufrag,
237 pwd = params.ice_pwd,
238 fp = params.fingerprint,
239 );
240
241 if direction == MediaDirection::RecvOnly && offer.is_simulcast() {
244 if let Some(id) = offer.rid_ext_id {
245 sdp.push_str(&format!(
246 "a=extmap:{id} urn:ietf:params:rtp-hdrext:sdes:rid\r\n"
247 ));
248 }
249 for rid in &offer.simulcast_rids {
250 sdp.push_str(&format!("a=rid:{rid} recv\r\n"));
251 }
252 sdp.push_str(&format!(
253 "a=simulcast:recv {}\r\n",
254 offer.simulcast_rids.join(";")
255 ));
256 }
257
258 if let Some(data_mid) = &offer.data_mid {
260 sdp.push_str(&format!(
261 "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n\
262c=IN IP4 0.0.0.0\r\n\
263a=mid:{data_mid}\r\n\
264a=ice-ufrag:{ufrag}\r\n\
265a=ice-pwd:{pwd}\r\n\
266a=fingerprint:{fp}\r\n\
267a=setup:passive\r\n\
268a=sctp-port:5000\r\n",
269 data_mid = data_mid,
270 ufrag = params.ice_ufrag,
271 pwd = params.ice_pwd,
272 fp = params.fingerprint,
273 ));
274 }
275
276 sdp
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 const OFFER: &str = "v=0\r\n\
284o=- 0 0 IN IP4 0.0.0.0\r\n\
285m=video 9 UDP/TLS/RTP/SAVPF 96 97\r\n\
286a=mid:vid\r\n\
287a=sendonly\r\n\
288a=rtpmap:96 H264/90000\r\n";
289
290 #[test]
291 fn parses_video_offer() {
292 let o = SdpOffer::parse(OFFER).unwrap();
293 assert_eq!(o.payload_type, 96);
294 assert_eq!(o.mid, "vid");
295 assert!(o.send);
296 }
297
298 #[test]
299 fn audio_only_offer_is_rejected() {
300 assert!(SdpOffer::parse("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n").is_none());
301 }
302
303 #[test]
304 fn parses_opus_audio_payload_alongside_video() {
305 let offer = "v=0\r\n\
306m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
307a=mid:vid\r\n\
308a=sendonly\r\n\
309a=rtpmap:96 H264/90000\r\n\
310m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n\
311a=mid:aud\r\n\
312a=sendonly\r\n\
313a=rtpmap:111 opus/48000/2\r\n";
314 let o = SdpOffer::parse(offer).unwrap();
315 assert_eq!(o.payload_type, 96);
316 assert_eq!(o.audio_payload_type, Some(111));
317 assert_eq!(SdpOffer::parse(OFFER).unwrap().audio_payload_type, None);
319 }
320
321 #[test]
322 fn answer_echoes_mid_and_payload_and_injects_crypto() {
323 let o = SdpOffer::parse(OFFER).unwrap();
324 let a = build_answer(
325 &o,
326 &SdpAnswerParams {
327 fingerprint: "sha-256 11:22".into(),
328 ice_ufrag: "uf".into(),
329 ice_pwd: "pw".into(),
330 },
331 );
332 assert!(a.contains("a=mid:vid"));
333 assert!(a.contains("m=video 9 UDP/TLS/RTP/SAVPF 96"));
334 assert!(a.contains("a=recvonly"));
335 assert!(a.contains("a=ice-ufrag:uf"));
336 assert!(a.contains("a=fingerprint:sha-256 11:22"));
337 assert!(a.contains("a=rtcp-fb:96 nack pli"));
338 }
339
340 const SIMULCAST_OFFER: &str = "v=0\r\n\
341o=- 0 0 IN IP4 0.0.0.0\r\n\
342m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
343a=mid:0\r\n\
344a=sendonly\r\n\
345a=rtpmap:96 H264/90000\r\n\
346a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid\r\n\
347a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n\
348a=rid:q send\r\n\
349a=rid:h send\r\n\
350a=rid:f send\r\n\
351a=simulcast:send q;h;f\r\n";
352
353 #[test]
354 fn parses_simulcast_layers_and_rid_extension() {
355 let o = SdpOffer::parse(SIMULCAST_OFFER).unwrap();
356 assert!(o.is_simulcast());
357 assert_eq!(o.rid_ext_id, Some(4)); assert_eq!(o.simulcast_rids, vec!["q", "h", "f"]);
359 }
360
361 #[test]
362 fn answer_echoes_simulcast_as_recv() {
363 let o = SdpOffer::parse(SIMULCAST_OFFER).unwrap();
364 let a = build_answer(
365 &o,
366 &SdpAnswerParams {
367 fingerprint: "sha-256 11:22".into(),
368 ice_ufrag: "uf".into(),
369 ice_pwd: "pw".into(),
370 },
371 );
372 assert!(a.contains("a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid"));
373 assert!(a.contains("a=rid:q recv"));
374 assert!(a.contains("a=rid:f recv"));
375 assert!(a.contains("a=simulcast:recv q;h;f"));
376 }
377
378 #[test]
379 fn parses_and_answers_data_channel() {
380 let offer = "v=0\r\n\
381o=- 0 0 IN IP4 0.0.0.0\r\n\
382m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
383a=mid:0\r\n\
384a=sendonly\r\n\
385a=rtpmap:96 H264/90000\r\n\
386m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n\
387a=mid:dc\r\n\
388a=sctp-port:5000\r\n";
389 let o = SdpOffer::parse(offer).unwrap();
390 assert!(o.has_data_channel());
391 assert_eq!(o.data_mid.as_deref(), Some("dc"));
392
393 let a = build_answer(
394 &o,
395 &SdpAnswerParams {
396 fingerprint: "sha-256 11:22".into(),
397 ice_ufrag: "uf".into(),
398 ice_pwd: "pw".into(),
399 },
400 );
401 assert!(a.contains("a=group:BUNDLE 0 dc"));
402 assert!(a.contains("m=application 9 UDP/DTLS/SCTP webrtc-datachannel"));
403 assert!(a.contains("a=mid:dc"));
404 assert!(a.contains("a=sctp-port:5000"));
405 }
406}