#![allow(clippy::cast_precision_loss)]
use std::fmt;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SdpError {
#[error("missing required SDP field: {0}")]
MissingField(String),
#[error("SDP parse error: {0}")]
ParseError(String),
#[error("invalid port number in SDP")]
InvalidPort,
}
pub type SdpResult<T> = Result<T, SdpError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SdpMediaType {
Video,
Audio,
AncillaryData,
}
impl SdpMediaType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Video => "video",
Self::Audio => "audio",
Self::AncillaryData => "application",
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"video" => Some(Self::Video),
"audio" => Some(Self::Audio),
"application" => Some(Self::AncillaryData),
_ => None,
}
}
}
impl fmt::Display for SdpMediaType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SdpAttribute {
pub name: String,
pub value: Option<String>,
}
impl SdpAttribute {
#[must_use]
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: Some(value.into()),
}
}
#[must_use]
pub fn flag(name: impl Into<String>) -> Self {
Self {
name: name.into(),
value: None,
}
}
#[must_use]
pub fn to_line(&self) -> String {
match &self.value {
Some(v) => format!("a={}:{}", self.name, v),
None => format!("a={}", self.name),
}
}
fn parse_raw(raw: &str) -> Self {
if let Some((name, value)) = raw.split_once(':') {
Self::new(name.trim(), value.trim())
} else {
Self::flag(raw.trim())
}
}
}
#[derive(Debug, Clone)]
pub struct SdpMediaSection {
pub media_type: SdpMediaType,
pub port: u16,
pub protocol: String,
pub formats: Vec<String>,
pub attributes: Vec<SdpAttribute>,
pub connection_addr: Option<String>,
}
impl SdpMediaSection {
#[must_use]
pub fn new(
media_type: SdpMediaType,
port: u16,
protocol: impl Into<String>,
formats: Vec<String>,
) -> Self {
Self {
media_type,
port,
protocol: protocol.into(),
formats,
attributes: Vec::new(),
connection_addr: None,
}
}
pub fn push_attribute(&mut self, attr: SdpAttribute) {
self.attributes.push(attr);
}
#[must_use]
pub fn find_attribute(&self, name: &str) -> Option<&SdpAttribute> {
self.attributes.iter().find(|a| a.name == name)
}
#[must_use]
pub fn to_lines(&self) -> Vec<String> {
let mut out = Vec::new();
let fmt_list = self.formats.join(" ");
out.push(format!(
"m={} {} {} {}",
self.media_type.as_str(),
self.port,
self.protocol,
fmt_list
));
if let Some(addr) = &self.connection_addr {
out.push(format!("c=IN IP4 {addr}"));
}
for attr in &self.attributes {
out.push(attr.to_line());
}
out
}
}
#[derive(Debug, Clone)]
pub struct SdpSession {
pub version: u8,
pub origin: String,
pub session_name: String,
pub attributes: Vec<SdpAttribute>,
pub media: Vec<SdpMediaSection>,
}
impl SdpSession {
#[must_use]
pub fn new(version: u8, origin: impl Into<String>, session_name: impl Into<String>) -> Self {
Self {
version,
origin: origin.into(),
session_name: session_name.into(),
attributes: Vec::new(),
media: Vec::new(),
}
}
pub fn push_attribute(&mut self, attr: SdpAttribute) {
self.attributes.push(attr);
}
pub fn push_media(&mut self, section: SdpMediaSection) {
self.media.push(section);
}
#[must_use]
pub fn media_of_type(&self, t: SdpMediaType) -> Vec<&SdpMediaSection> {
self.media.iter().filter(|m| m.media_type == t).collect()
}
#[must_use]
pub fn find_attribute(&self, name: &str) -> Option<&SdpAttribute> {
self.attributes.iter().find(|a| a.name == name)
}
}
impl fmt::Display for SdpSession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "v={}\r\n", self.version)?;
write!(f, "o={}\r\n", self.origin)?;
write!(f, "s={}\r\n", self.session_name)?;
write!(f, "t=0 0\r\n")?;
for attr in &self.attributes {
write!(f, "{}\r\n", attr.to_line())?;
}
for section in &self.media {
for line in section.to_lines() {
write!(f, "{line}\r\n")?;
}
}
Ok(())
}
}
pub struct SdpParser;
impl SdpParser {
pub fn parse(sdp: &str) -> SdpResult<SdpSession> {
let mut version: Option<u8> = None;
let mut origin: Option<String> = None;
let mut session_name: Option<String> = None;
let mut session_attrs: Vec<SdpAttribute> = Vec::new();
let mut current_media: Option<SdpMediaSection> = None;
let mut media_sections: Vec<SdpMediaSection> = Vec::new();
for raw_line in sdp.lines() {
let line = raw_line.trim_end_matches('\r').trim();
if line.is_empty() {
continue;
}
let (type_char, value) = Self::split_line(line)?;
match type_char {
'v' => {
version = Some(
value
.parse::<u8>()
.map_err(|_| SdpError::ParseError(format!("bad v= value: {value}")))?,
);
}
'o' => {
origin = Some(value.to_owned());
}
's' => {
session_name = Some(value.to_owned());
}
't' => {
}
'a' => {
let attr = SdpAttribute::parse_raw(value);
if let Some(ref mut sec) = current_media {
sec.push_attribute(attr);
} else {
session_attrs.push(attr);
}
}
'c' => {
let addr = Self::parse_connection(value)?;
if let Some(ref mut sec) = current_media {
sec.connection_addr = Some(addr);
}
}
'm' => {
if let Some(prev) = current_media.take() {
media_sections.push(prev);
}
current_media = Some(Self::parse_media_line(value)?);
}
_ => {
}
}
}
if let Some(last) = current_media {
media_sections.push(last);
}
let version = version.ok_or_else(|| SdpError::MissingField("v=".to_owned()))?;
let origin = origin.ok_or_else(|| SdpError::MissingField("o=".to_owned()))?;
let session_name = session_name.ok_or_else(|| SdpError::MissingField("s=".to_owned()))?;
let mut session = SdpSession::new(version, origin, session_name);
session.attributes = session_attrs;
session.media = media_sections;
Ok(session)
}
fn split_line(line: &str) -> SdpResult<(char, &str)> {
let mut chars = line.chars();
let type_char = chars
.next()
.ok_or_else(|| SdpError::ParseError("empty line".to_owned()))?;
let rest = chars.as_str();
if !rest.starts_with('=') {
return Err(SdpError::ParseError(format!(
"expected '=' after type char, got: {line}"
)));
}
Ok((type_char, &rest[1..]))
}
fn parse_connection(value: &str) -> SdpResult<String> {
let parts: Vec<&str> = value.split_whitespace().collect();
if parts.len() < 3 {
return Err(SdpError::ParseError(format!("malformed c= line: {value}")));
}
Ok(parts[2].to_owned())
}
fn parse_media_line(value: &str) -> SdpResult<SdpMediaSection> {
let mut parts = value.splitn(4, ' ');
let media_str = parts
.next()
.ok_or_else(|| SdpError::MissingField("m= media type".to_owned()))?;
let port_str = parts
.next()
.ok_or_else(|| SdpError::MissingField("m= port".to_owned()))?;
let protocol = parts
.next()
.ok_or_else(|| SdpError::MissingField("m= protocol".to_owned()))?;
let fmt_str = parts.next().unwrap_or("");
let media_type = SdpMediaType::from_str(media_str)
.ok_or_else(|| SdpError::ParseError(format!("unknown media type: {media_str}")))?;
let port: u16 = port_str
.split('/')
.next()
.unwrap_or(port_str)
.parse()
.map_err(|_| SdpError::InvalidPort)?;
let formats = fmt_str
.split_whitespace()
.map(str::to_owned)
.collect::<Vec<_>>();
Ok(SdpMediaSection::new(media_type, port, protocol, formats))
}
}
#[derive(Debug)]
pub struct SdpBuilder {
session: SdpSession,
}
impl SdpBuilder {
#[must_use]
pub fn new(session_name: impl Into<String>, origin: impl Into<String>) -> Self {
Self {
session: SdpSession::new(0, origin, session_name),
}
}
#[must_use]
pub fn add_session_attribute(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> Self {
self.session.push_attribute(SdpAttribute::new(name, value));
self
}
#[must_use]
pub fn add_session_flag(mut self, name: impl Into<String>) -> Self {
self.session.push_attribute(SdpAttribute::flag(name));
self
}
#[must_use]
pub fn add_video_section(
self,
port: u16,
protocol: &str,
formats: &[&str],
connection_addr: Option<&str>,
) -> Self {
self.add_media_section(
SdpMediaType::Video,
port,
protocol,
formats,
connection_addr,
)
}
#[must_use]
pub fn add_audio_section(
self,
port: u16,
protocol: &str,
formats: &[&str],
connection_addr: Option<&str>,
) -> Self {
self.add_media_section(
SdpMediaType::Audio,
port,
protocol,
formats,
connection_addr,
)
}
#[must_use]
pub fn add_ancillary_section(
self,
port: u16,
protocol: &str,
formats: &[&str],
connection_addr: Option<&str>,
) -> Self {
self.add_media_section(
SdpMediaType::AncillaryData,
port,
protocol,
formats,
connection_addr,
)
}
#[must_use]
pub fn add_media_section(
mut self,
media_type: SdpMediaType,
port: u16,
protocol: &str,
formats: &[&str],
connection_addr: Option<&str>,
) -> Self {
let mut section = SdpMediaSection::new(
media_type,
port,
protocol,
formats.iter().map(|s| (*s).to_owned()).collect(),
);
section.connection_addr = connection_addr.map(str::to_owned);
self.session.push_media(section);
self
}
#[must_use]
pub fn with_media_attribute(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> Self {
if let Some(sec) = self.session.media.last_mut() {
sec.push_attribute(SdpAttribute::new(name, value));
}
self
}
#[must_use]
pub fn with_media_flag(mut self, name: impl Into<String>) -> Self {
if let Some(sec) = self.session.media.last_mut() {
sec.push_attribute(SdpAttribute::flag(name));
}
self
}
#[must_use]
pub fn build(self) -> SdpSession {
self.session
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_simple_session() -> SdpSession {
SdpBuilder::new("TestSession", "- 0 0 IN IP4 10.0.0.1")
.add_video_section(5004, "RTP/AVP", &["96"], Some("239.100.0.1"))
.with_media_attribute("rtpmap", "96 raw/90000")
.add_audio_section(5006, "RTP/AVP", &["97"], Some("239.100.0.2"))
.with_media_attribute("rtpmap", "97 L24/48000/2")
.build()
}
#[test]
fn test_round_trip_media_count() {
let session = build_simple_session();
let text = session.to_string();
let parsed = SdpParser::parse(&text).expect("should parse round-trip");
assert_eq!(parsed.media.len(), 2);
}
#[test]
fn test_round_trip_session_name() {
let session = build_simple_session();
let text = session.to_string();
let parsed = SdpParser::parse(&text).expect("should parse round-trip");
assert_eq!(parsed.session_name, "TestSession");
}
#[test]
fn test_round_trip_video_section() {
let session = build_simple_session();
let text = session.to_string();
let parsed = SdpParser::parse(&text).expect("should parse round-trip");
let videos = parsed.media_of_type(SdpMediaType::Video);
assert_eq!(videos.len(), 1);
assert_eq!(videos[0].port, 5004);
}
#[test]
fn test_round_trip_audio_section() {
let session = build_simple_session();
let text = session.to_string();
let parsed = SdpParser::parse(&text).expect("should parse round-trip");
let audios = parsed.media_of_type(SdpMediaType::Audio);
assert_eq!(audios.len(), 1);
assert_eq!(audios[0].port, 5006);
}
#[test]
fn test_round_trip_connection_addr() {
let session = build_simple_session();
let text = session.to_string();
let parsed = SdpParser::parse(&text).expect("should parse round-trip");
assert_eq!(
parsed.media[0].connection_addr.as_deref(),
Some("239.100.0.1")
);
}
#[test]
fn test_round_trip_media_attribute() {
let session = build_simple_session();
let text = session.to_string();
let parsed = SdpParser::parse(&text).expect("should parse round-trip");
let attr = parsed.media[0].find_attribute("rtpmap");
assert!(attr.is_some());
assert_eq!(
attr.expect("attr is Some, checked above").value.as_deref(),
Some("96 raw/90000")
);
}
#[test]
fn test_missing_version_error() {
let sdp = "o=- 0 0 IN IP4 127.0.0.1\r\ns=Test\r\n";
let result = SdpParser::parse(sdp);
assert!(matches!(result, Err(SdpError::MissingField(_))));
}
#[test]
fn test_missing_session_name_error() {
let sdp = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n";
let result = SdpParser::parse(sdp);
assert!(matches!(result, Err(SdpError::MissingField(_))));
}
#[test]
fn test_invalid_port_error() {
let sdp = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=Test\r\nm=video notaport RTP/AVP 96\r\n";
let result = SdpParser::parse(sdp);
assert!(matches!(result, Err(SdpError::InvalidPort)));
}
#[test]
fn test_flag_attribute_extraction() {
let session = SdpBuilder::new("S", "- 0 0 IN IP4 127.0.0.1")
.add_session_flag("recvonly")
.build();
let text = session.to_string();
let parsed = SdpParser::parse(&text).expect("should parse");
let attr = parsed.find_attribute("recvonly");
assert!(attr.is_some());
assert!(attr.expect("attr is Some, checked above").value.is_none());
}
#[test]
fn test_ancillary_section_round_trip() {
let session = SdpBuilder::new("S", "- 0 0 IN IP4 127.0.0.1")
.add_ancillary_section(5010, "RTP/AVP", &["100"], Some("239.100.0.3"))
.build();
let text = session.to_string();
let parsed = SdpParser::parse(&text).expect("should parse");
let anc = parsed.media_of_type(SdpMediaType::AncillaryData);
assert_eq!(anc.len(), 1);
assert_eq!(anc[0].port, 5010);
}
#[test]
fn test_multiple_formats_parsed() {
let session = SdpBuilder::new("S", "- 0 0 IN IP4 127.0.0.1")
.add_video_section(5004, "RTP/AVP", &["96", "97", "98"], None)
.build();
let text = session.to_string();
let parsed = SdpParser::parse(&text).expect("should parse");
assert_eq!(parsed.media[0].formats.len(), 3);
}
}