Skip to main content

moq_rtc/
sdp.rs

1//! SDP plumbing.
2//!
3//! WHIP/WHEP both shovel SDP between a peer and str0m as `application/sdp`
4//! request/response bodies. The only thing we add on top of str0m's offer/answer
5//! parse/serialize is a tiny wrapper to keep the call sites readable.
6
7use std::borrow::Cow;
8use std::str::FromStr;
9
10use crate::{Error, Result};
11
12/// Parse an `application/sdp` body as an offer.
13pub fn parse_offer(body: &str) -> Result<str0m::change::SdpOffer> {
14	str0m::change::SdpOffer::from_sdp_string(body).map_err(|err| Error::InvalidSdp(err.to_string()))
15}
16
17/// Serialize an SDP answer for the `application/sdp` response body.
18///
19/// str0m can emit a *rejected* media line (port 0) with an EMPTY format list,
20/// e.g. `m=audio 0 UDP/TLS/RTP/SAVPF `. This happens when we restrict the
21/// `CodecConfig` (see [`crate::session::rtc_config_with_codecs`]) and the peer's offer
22/// carries a codec the broadcast can't egress -- the classic case being AAC
23/// audio over WHEP, which we can't carry, so the audio m-line comes back
24/// rejected with no payload. An m-line with no `<fmt>` violates RFC 4566, and a
25/// browser rejects the WHOLE answer in `setRemoteDescription`, killing playback
26/// of the media (e.g. video) that WAS negotiated. So we give any such line a
27/// placeholder static payload; the media stays rejected (port 0), so the
28/// placeholder is never used.
29pub fn render_answer(answer: &str0m::change::SdpAnswer) -> String {
30	// SDP uses CRLF line endings (RFC 4566); splitting and rejoining on "\r\n"
31	// round-trips exactly, including the trailing CRLF.
32	answer
33		.to_sdp_string()
34		.split("\r\n")
35		.map(ensure_media_format)
36		.collect::<Vec<Cow<str>>>()
37		.join("\r\n")
38}
39
40/// Give an `m=` line a placeholder format payload when it has none (see
41/// [`render_answer`]); every other line passes through untouched.
42fn ensure_media_format(line: &str) -> Cow<'_, str> {
43	if !line.starts_with("m=") {
44		return Cow::Borrowed(line);
45	}
46	// m=<media> <port> <proto> <fmt>...; fewer than 4 tokens means no <fmt>.
47	if line.split_whitespace().count() >= 4 {
48		return Cow::Borrowed(line);
49	}
50	Cow::Owned(format!("{} 0", line.trim_end()))
51}
52
53/// Build a stable WHIP/WHEP resource identifier from a UUID v4.
54pub fn new_resource_id() -> String {
55	uuid::Uuid::new_v4().to_string()
56}
57
58/// Parse a `Location:`-style resource path into its trailing UUID component.
59///
60/// WHIP DELETEs come back to `/<broadcast>/<resource-id>`; this strips
61/// everything but the id so the gateway can look up the session.
62pub fn parse_resource_id(path: &str) -> Result<uuid::Uuid> {
63	let last = path
64		.rsplit('/')
65		.find(|s| !s.is_empty())
66		.ok_or_else(|| Error::InvalidSdp("missing resource id".into()))?;
67	uuid::Uuid::from_str(last).map_err(|err| Error::InvalidSdp(err.to_string()))
68}
69
70#[cfg(test)]
71mod tests {
72	use super::ensure_media_format;
73
74	#[test]
75	fn rejected_mline_with_no_format_gets_placeholder() {
76		// str0m's malformed rejected audio line.
77		assert_eq!(
78			ensure_media_format("m=audio 0 UDP/TLS/RTP/SAVPF "),
79			"m=audio 0 UDP/TLS/RTP/SAVPF 0"
80		);
81		assert_eq!(
82			ensure_media_format("m=audio 0 UDP/TLS/RTP/SAVPF"),
83			"m=audio 0 UDP/TLS/RTP/SAVPF 0"
84		);
85	}
86
87	#[test]
88	fn well_formed_lines_are_untouched() {
89		let video = "m=video 9 UDP/TLS/RTP/SAVPF 96 97";
90		assert_eq!(ensure_media_format(video), video);
91		let attr = "a=ice-ufrag:abcd";
92		assert_eq!(ensure_media_format(attr), attr);
93	}
94}