use std::fmt;
use std::ops::{Deref, DerefMut};
use crate::Rtc;
use crate::RtcError;
use crate::channel::ChannelId;
use crate::crypto::Fingerprint;
use crate::format::CodecConfig;
use crate::format::PayloadParams;
use crate::media::{Media, Rids, Simulcast};
use crate::packet::MediaKind;
use crate::rtp_::MidRid;
use crate::rtp_::Rid;
use crate::rtp_::{Direction, Extension, ExtensionMap, Mid, Pt, Ssrc};
use crate::sctp::ChannelConfig;
use crate::sctp::RtcSctp;
use crate::sdp::{self, MediaAttribute, MediaLine, MediaType, Msid, Sdp};
use crate::sdp::{Proto, SessionAttribute, Setup, SimulcastGroups};
use crate::session::Session;
use crate::{Candidate, IceCreds};
use str0m_proto::Id;
pub use crate::sdp::{SdpAnswer, SdpOffer};
use crate::streams::{DEFAULT_RTX_CACHE_DURATION, DEFAULT_RTX_RATIO_CAP, Streams};
pub struct SdpApi<'a> {
rtc: &'a mut Rtc,
changes: Changes,
}
impl<'a> SdpApi<'a> {
pub(crate) fn new(rtc: &'a mut Rtc) -> Self {
SdpApi {
rtc,
changes: Changes::default(),
}
}
pub fn accept_offer(self, offer: SdpOffer) -> Result<SdpAnswer, RtcError> {
debug!("Accept offer");
self.rtc.next_change_id();
if offer.media_lines.is_empty() {
return Err(RtcError::RemoteSdp("No m-lines in offer".into()));
}
if self.rtc.ice.ice_lite() && offer.session.ice_lite() {
return Err(RtcError::RemoteSdp(
"Both peers being ICE-Lite not supported".into(),
));
}
add_ice_details(self.rtc, &offer, None)?;
if self.rtc.remote_fingerprint.is_none() {
if let Some(f) = offer.fingerprint() {
self.rtc.remote_fingerprint = Some(f);
} else {
self.rtc.disconnect();
return Err(RtcError::RemoteSdp("missing a=fingerprint".into()));
}
}
if !self.rtc.dtls.is_inited() {
self.rtc.ice.set_controlling(offer.session.ice_lite());
}
init_dtls(self.rtc, &offer)?;
let remote_sctp_init = offer.sctp_init().map(|v| v.to_owned());
let has_snap = process_remote_sctp_init(&mut self.rtc.sctp, remote_sctp_init.as_deref())?;
apply_offer(&mut self.rtc.session, offer)?;
let client = self.rtc.dtls.is_active().expect("DTLS active to be set");
if has_snap && !self.rtc.sctp.is_inited() && !self.rtc.sctp.ensure_local_snap_init() {
warn!("Failed to generate SNAP INIT chunk, degrading to non-SNAP");
}
if self.rtc.session.app().is_some() {
let init_data = self.rtc.sctp.build_snap_init_data();
self.rtc.try_init_sctp(client, init_data)?;
}
let params = AsSdpParams::new(self.rtc, None);
let sdp = as_sdp(&self.rtc.session, params);
debug!("Create answer");
Ok(sdp.into())
}
pub fn accept_answer(
self,
mut pending: SdpPendingOffer,
answer: SdpAnswer,
) -> Result<(), RtcError> {
debug!("Accept answer");
drop(self.changes);
if !self.rtc.is_correct_change_id(pending.change_id) {
return Err(RtcError::ChangesOutOfOrder);
}
if self.rtc.ice.ice_lite() && answer.session.ice_lite() {
return Err(RtcError::RemoteSdp(
"Both peers being ICE-Lite not supported".into(),
));
}
add_ice_details(self.rtc, &answer, Some(&pending))?;
init_dtls(self.rtc, &answer)?;
if self.rtc.remote_fingerprint.is_none() {
if let Some(f) = answer.fingerprint() {
self.rtc.remote_fingerprint = Some(f);
} else {
self.rtc.disconnect();
return Err(RtcError::RemoteSdp("missing a=fingerprint".into()));
}
}
let remote_sctp_init = answer.sctp_init().map(|v| v.to_owned());
let expected_snap_answer =
!self.rtc.sctp.is_inited() && self.rtc.sctp.local_sctp_init_for_sdp().is_some();
let has_snap = process_remote_sctp_init(&mut self.rtc.sctp, remote_sctp_init.as_deref())?;
let new_channels = pending.changes.take_new_channels();
apply_answer(&mut self.rtc.session, pending.changes, answer)?;
let client = self.rtc.dtls.is_active().expect("DTLS to be inited");
if expected_snap_answer && !has_snap {
debug!("Remote answer did not accept SNAP, falling back to regular SCTP handshake");
self.rtc.sctp.disable_pending_snap();
}
if self.rtc.session.app().is_some() {
let init_data = self.rtc.sctp.build_snap_init_data();
self.rtc.try_init_sctp(client, init_data)?;
}
for (id, config) in new_channels {
self.rtc.chan.confirm(id, config);
}
Ok(())
}
pub fn has_changes(&self) -> bool {
!self.changes.0.is_empty()
}
pub fn add_media(
&mut self,
kind: MediaKind,
dir: Direction,
stream_id: Option<String>,
track_id: Option<String>,
simulcast: Option<crate::media::Simulcast>,
) -> Mid {
let mid = self.rtc.new_mid();
fn is_token_char(c: &char) -> bool {
let u = *c as u32;
u == 0x21
|| (0x23..=0x27).contains(&u)
|| (0x2a..=0x2b).contains(&u)
|| (0x2d..=0x2e).contains(&u)
|| (0x30..=0x39).contains(&u)
|| (0x41..=0x5a).contains(&u)
|| (0x5e..0x7e).contains(&u)
}
let stream_id = if let Some(stream_id) = stream_id {
stream_id.chars().filter(is_token_char).take(64).collect()
} else {
Id::<20>::random().to_string()
};
let track_id = if let Some(track_id) = track_id {
track_id.chars().filter(is_token_char).take(64).collect()
} else {
Id::<20>::random().to_string()
};
let mut ssrcs = Vec::new();
let main_ssrc_count = simulcast.as_ref().map(|s| s.send.len()).unwrap_or(1);
for _ in 0..main_ssrc_count {
let rtx = kind.is_video().then(|| self.rtc.session.streams.new_ssrc());
ssrcs.push((self.rtc.session.streams.new_ssrc(), rtx));
}
let msid = Msid {
stream_id,
track_id: track_id.clone(),
};
let add = AddMedia {
mid,
cname: track_id,
msid,
kind,
dir,
ssrcs,
simulcast,
pts: vec![],
exts: ExtensionMap::empty(),
index: 0,
};
self.changes.0.push(Change::AddMedia(add));
mid
}
pub fn set_direction(&mut self, mid: Mid, dir: Direction) {
let changed = self.rtc.session.set_direction(mid, dir);
if changed {
self.changes.0.push(Change::Direction(mid, dir));
}
}
pub fn add_channel(&mut self, label: String) -> ChannelId {
self.add_channel_with_config(ChannelConfig {
label,
..Default::default()
})
}
pub fn add_channel_with_config(&mut self, config: ChannelConfig) -> ChannelId {
let has_media = self.rtc.session.app().is_some();
let changes_contains_add_app = self.changes.contains_add_app();
if !has_media && !changes_contains_add_app {
let mid = self.rtc.new_mid();
self.changes.0.push(Change::AddApp(mid));
}
let id = self.rtc.chan.new_channel(&config);
self.changes.0.push(Change::AddChannel((id, config)));
id
}
pub fn ice_restart(&mut self, keep_local_candidates: bool) -> IceCreds {
self.changes
.retain(|c| !matches!(c, Change::IceRestart(_, _)));
let new_creds = IceCreds::new();
self.changes
.push(Change::IceRestart(new_creds.clone(), keep_local_candidates));
new_creds
}
pub fn apply(self) -> Option<(SdpOffer, SdpPendingOffer)> {
if self.changes.is_empty() {
return None;
}
let change_id = self.rtc.next_change_id();
let requires_negotiation = self.changes.0.iter().any(requires_negotiation);
if requires_negotiation {
let offer = create_offer(self.rtc, &self.changes);
let pending = SdpPendingOffer {
change_id,
changes: self.changes,
};
debug!("Create offer");
Some((offer, pending))
} else {
debug!("Apply direct changes");
apply_direct_changes(self.rtc, self.changes);
None
}
}
pub fn merge(&mut self, mut pending_offer: SdpPendingOffer) {
pending_offer.retain_relevant(self.rtc);
pending_offer.changes.0.append(&mut self.changes.0);
self.changes.0 = pending_offer.changes.0;
}
}
pub struct SdpPendingOffer {
change_id: usize,
changes: Changes,
}
impl SdpPendingOffer {
fn retain_relevant(&mut self, rtc: &Rtc) {
fn is_relevant(rtc: &Rtc, c: &Change) -> bool {
match c {
Change::AddMedia(v) => rtc.media(v.mid).is_none(),
Change::AddApp(_) => rtc.session.app().is_none(),
Change::AddChannel(v) => rtc.chan.stream_id_by_channel_id(v.0).is_none(),
Change::Direction(m, d) => {
rtc.media(*m).map(|m| m.direction() != *d).unwrap_or(false)
}
Change::IceRestart(v, _) => rtc.ice.local_credentials() != v,
}
}
self.changes.retain(|c| is_relevant(rtc, c));
}
}
#[derive(Default)]
pub(crate) struct Changes(pub Vec<Change>);
impl Changes {
fn ice_restart(&self) -> Option<(IceCreds, bool)> {
self.iter().find_map(|c| match c {
Change::IceRestart(creds, keep_local_candidates) => {
Some((creds.clone(), *keep_local_candidates))
}
_ => None,
})
}
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub(crate) enum Change {
AddMedia(AddMedia),
AddApp(Mid),
AddChannel((ChannelId, ChannelConfig)),
Direction(Mid, Direction),
IceRestart(IceCreds, bool),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AddMedia {
pub mid: Mid,
pub cname: String,
pub msid: Msid,
pub kind: MediaKind,
pub dir: Direction,
pub ssrcs: Vec<(Ssrc, Option<Ssrc>)>,
pub simulcast: Option<Simulcast>,
pub pts: Vec<Pt>,
pub exts: ExtensionMap,
pub index: usize,
}
impl Deref for Changes {
type Target = Vec<Change>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Changes {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
fn requires_negotiation(c: &Change) -> bool {
match c {
Change::IceRestart(_, _) => true,
Change::AddMedia(_) => true,
Change::AddApp(_) => true,
Change::AddChannel(_) => false,
Change::Direction(_, _) => true,
}
}
fn apply_direct_changes(rtc: &mut Rtc, mut changes: Changes) {
let new_channels = changes.take_new_channels();
for (id, config) in new_channels {
rtc.chan.confirm(id, config);
}
}
fn create_offer(rtc: &mut Rtc, changes: &Changes) -> SdpOffer {
if !rtc.dtls.is_inited() {
rtc.ice.set_controlling(!rtc.ice.ice_lite());
}
if rtc.sctp.snap_enabled()
&& (rtc.session.app().is_some() || changes.contains_add_app())
&& !rtc.sctp.is_inited()
&& !rtc.sctp.ensure_local_snap_init()
{
warn!("Failed to generate SNAP INIT chunk, degrading to non-SNAP");
}
let params = AsSdpParams::new(rtc, Some(changes));
let sdp = as_sdp(&rtc.session, params);
sdp.into()
}
fn add_ice_details(
rtc: &mut Rtc,
sdp: &Sdp,
pending: Option<&SdpPendingOffer>,
) -> Result<(), RtcError> {
let Some(creds) = sdp.ice_creds() else {
return Err(RtcError::RemoteSdp("missing a=ice-ufrag/pwd".into()));
};
let ice_restart = match rtc.ice.remote_credentials() {
Some(v) => *v != creds,
None => false,
};
if ice_restart {
let (new_local_creds, keep_local_candidates) = if let Some(pending) = pending {
pending.changes.ice_restart().ok_or_else(||
RtcError::RemoteSdp(
"Ice restart in answer without one in the preceeding offer".into(),
))?
} else {
(IceCreds::new(), true)
};
rtc.ice
.ice_restart(new_local_creds.clone(), keep_local_candidates);
}
rtc.ice.set_remote_credentials(creds);
for r in sdp.ice_candidates() {
rtc.ice.add_remote_candidate(r.clone());
}
Ok(())
}
fn init_dtls(rtc: &mut Rtc, remote_sdp: &Sdp) -> Result<(), RtcError> {
let setup = match remote_sdp.setup() {
Some(v) => match v {
Setup::ActPass => Setup::Passive,
_ => v.invert(),
},
None => {
warn!("Missing a=setup line");
Setup::Passive
}
};
let active = setup == Setup::Active;
rtc.init_dtls(active)?;
Ok(())
}
fn process_remote_sctp_init(
sctp: &mut RtcSctp,
remote_init: Option<&str>,
) -> Result<bool, RtcError> {
if let Some(remote_init_str) = remote_init {
if sctp.is_inited() {
match sctp.snap_remote_init_string() {
Some(cached) if cached == remote_init_str => {
debug!("Remote re-sent expected a=sctp-init for established association");
}
Some(_) => {
return Err(RtcError::RemoteSdp(
"Changed a=sctp-init for existing SCTP association".into(),
));
}
None => {
debug!("Ignoring a=sctp-init for non-SNAP established SCTP association");
}
}
Ok(false)
} else if sctp.set_remote_snap_init_string(remote_init_str) {
Ok(true)
} else {
debug!("Ignoring malformed a=sctp-init");
Ok(false)
}
} else if sctp.is_snap_established() {
Err(RtcError::RemoteSdp(
"Missing a=sctp-init for established SNAP SCTP association".into(),
))
} else {
Ok(false)
}
}
fn as_sdp(session: &Session, params: AsSdpParams) -> Sdp {
let (media_lines, mids, stream_ids) = {
let mut v = as_media_lines(session);
let mut new_lines = vec![];
let new_index_start = v.len();
if let Some(pending) = params.pending {
new_lines = pending
.as_new_medias(new_index_start, &session.codec_config, &session.exts)
.collect();
}
v.extend(new_lines.iter().map(|n| n as &dyn AsSdpMediaLine));
let mut lines = v
.iter()
.map(|m| {
let include_candidates = m.index() == 0;
let attrs = params.media_attributes(include_candidates);
let mut ssrcs = session.streams.ssrcs_tx(m.mid());
if let Some(pending) = params.pending {
ssrcs.extend(pending.ssrcs_for_mid(m.mid()))
}
let params: Vec<_> = session
.codec_config
.all_for_kind(m.kind())
.cloned()
.collect();
m.as_media_line(attrs, &ssrcs, &session.exts, ¶ms)
})
.collect::<Vec<_>>();
if let Some(sctp_init) = ¶ms.local_sctp_init {
for line in &mut lines {
if line.typ.is_channel() {
line.attrs
.push(sdp::MediaAttribute::SctpInit(sctp_init.clone()));
}
}
}
if let Some(pending) = params.pending {
pending.apply_to(&mut lines);
}
let mids = lines
.iter()
.filter(|l| !l.disabled)
.map(|l| l.mid())
.collect();
let mut stream_ids = vec![];
for msid in v.iter().filter_map(|v| v.msid()) {
if !stream_ids.contains(&msid.stream_id) {
stream_ids.push(msid.stream_id.clone());
}
}
(lines, mids, stream_ids)
};
let mut attrs = vec![
SessionAttribute::Group {
typ: "BUNDLE".into(),
mids,
},
SessionAttribute::AllowMixedExts,
SessionAttribute::MsidSemantic {
semantic: "WMS".to_string(),
stream_ids,
},
];
if session.ice_lite {
attrs.push(SessionAttribute::IceLite);
}
Sdp {
session: sdp::Session {
id: session.id(),
bw: None,
attrs,
},
media_lines,
}
}
fn apply_offer(session: &mut Session, offer: SdpOffer) -> Result<(), RtcError> {
offer.assert_consistency()?;
update_session(session, &offer);
let bundle_mids = offer.bundle_mids();
let new_lines = sync_medias(session, &offer).map_err(RtcError::RemoteSdp)?;
add_new_lines(session, &new_lines, true, bundle_mids).map_err(RtcError::RemoteSdp)?;
ensure_stream_tx(session);
Ok(())
}
fn apply_answer(
session: &mut Session,
pending: Changes,
answer: SdpAnswer,
) -> Result<(), RtcError> {
answer.assert_consistency()?;
update_session(session, &answer);
let bundle_mids = answer.bundle_mids();
let new_lines = sync_medias(session, &answer).map_err(RtcError::RemoteSdp)?;
if let Some(err) = pending.ensure_correct_answer(&new_lines) {
return Err(RtcError::RemoteSdp(err));
}
add_new_lines(session, &new_lines, false, bundle_mids).map_err(RtcError::RemoteSdp)?;
add_pending_changes(session, pending);
ensure_stream_tx(session);
Ok(())
}
fn ensure_stream_tx(session: &mut Session) {
for media in &session.medias {
if !media.direction().is_sending() {
continue;
}
let mut rids: Vec<Option<Rid>> = vec![];
if let Some(sim) = media.simulcast() {
for layer in &*sim.send {
let rid: Rid = layer.restriction_id.0.as_str().into();
rids.push(Some(rid));
}
} else {
rids.push(None);
}
let has_rtx = session
.codec_config
.iter()
.filter(|p| media.remote_pts().contains(&p.pt))
.any(|p| p.resend().is_some());
for rid in rids {
let midrid = MidRid(media.mid(), rid);
let has_stream = session.streams.stream_tx_by_midrid(midrid).is_some();
if has_stream {
continue;
}
let (ssrc, rtx) = if has_rtx {
let (ssrc, rtx) = session.streams.new_ssrc_pair();
(ssrc, Some(rtx))
} else {
let ssrc = session.streams.new_ssrc();
(ssrc, None)
};
let stream = session.streams.declare_stream_tx(ssrc, rtx, midrid);
let size = if media.kind().is_audio() {
session.send_buffer_audio
} else {
session.send_buffer_video
};
stream.set_rtx_cache(size, DEFAULT_RTX_CACHE_DURATION, DEFAULT_RTX_RATIO_CAP);
}
}
}
fn add_pending_changes(session: &mut Session, pending: Changes) {
for change in pending.0 {
let add_media = match change {
Change::AddMedia(v) => v,
_ => continue,
};
let media = session
.medias
.iter_mut()
.find(|m| m.mid() == add_media.mid)
.expect("Media to be added for pending mid");
media.set_cname(add_media.cname);
media.set_msid(add_media.msid);
let layers = add_media.simulcast.map(|x| x.send).unwrap_or(vec![]);
for (i, (ssrc, rtx)) in add_media.ssrcs.into_iter().enumerate() {
let maybe_layer = layers.get(i).cloned();
let midrid = MidRid(add_media.mid, maybe_layer.map(|layer| layer.rid));
let stream = session.streams.declare_stream_tx(ssrc, rtx, midrid);
let size = if media.kind().is_audio() {
session.send_buffer_audio
} else {
session.send_buffer_video
};
stream.set_rtx_cache(size, DEFAULT_RTX_CACHE_DURATION, DEFAULT_RTX_RATIO_CAP);
}
}
}
fn sync_medias<'a>(session: &mut Session, sdp: &'a Sdp) -> Result<Vec<&'a MediaLine>, String> {
let mut new_lines = Vec::with_capacity(sdp.media_lines.len());
let bundle_mids = sdp.bundle_mids();
for (idx, m) in sdp.media_lines.iter().enumerate() {
match m.typ {
MediaType::Application => {
if let Some((_, index)) = session.app() {
if idx != *index {
return index_err(m.mid());
}
continue;
}
}
MediaType::Audio | MediaType::Video => {
if let Some(media) = session.medias.iter_mut().find(|l| l.mid() == m.mid()) {
if idx != media.index() {
return index_err(m.mid());
}
update_media(
media,
m,
&session.codec_config,
&session.exts,
&mut session.streams,
bundle_mids,
);
continue;
}
}
_ => {
continue;
}
}
new_lines.push(m);
}
fn index_err<T>(mid: Mid) -> Result<T, String> {
Err(format!("Changed order for m-line with mid: {mid}"))
}
Ok(new_lines)
}
fn add_new_lines(
session: &mut Session,
new_lines: &[&MediaLine],
is_offer: bool,
bundle_mids: Option<&[Mid]>,
) -> Result<(), String> {
for m in new_lines {
let idx = session.line_count();
if m.typ.is_media() {
let mut media = Media::from_remote_media_line(m, idx, is_offer);
let is_in_bundle = bundle_mids
.map(|mids| mids.contains(&m.mid()))
.unwrap_or(false);
let is_rejected = m.disabled && !is_in_bundle;
media.need_open_event = is_offer && !is_rejected;
session
.codec_config
.update_params(&m.rtp_params(), m.direction());
session.exts.remap(&m.extmaps());
update_media(
&mut media,
m,
&session.codec_config,
&session.exts,
&mut session.streams,
bundle_mids,
);
session.add_media(media);
} else if m.typ.is_channel() {
session.set_app(m.mid(), idx)?;
} else {
return Err(format!(
"New m-line is neither media nor channel: {}",
m.mid()
));
}
}
Ok(())
}
fn update_session(session: &mut Session, sdp: &Sdp) {
let has_transport_cc = sdp
.media_lines
.iter()
.any(|m| m.rtp_params().iter().any(|p| p.fb_transport_cc));
let has_twcc_header = session
.exts
.id_of(Extension::TransportSequenceNumber)
.is_some();
if has_transport_cc && has_twcc_header {
session.enable_twcc_feedback();
}
}
fn as_media_lines(session: &Session) -> Vec<&dyn AsSdpMediaLine> {
let mut v = vec![];
if let Some(app) = session.app() {
v.push(app as &dyn AsSdpMediaLine);
}
v.extend(session.medias().iter().map(|m| m as &dyn AsSdpMediaLine));
v.sort_by_key(|f| f.index());
v
}
fn update_media(
media: &mut Media,
m: &MediaLine,
config: &CodecConfig,
exts: &ExtensionMap,
streams: &mut Streams,
bundle_mids: Option<&[Mid]>,
) {
let is_in_bundle = bundle_mids
.map(|mids| mids.contains(&m.mid()))
.unwrap_or(false);
let is_rejected = m.disabled && !is_in_bundle;
if is_rejected {
if !media.disabled() {
debug!(
"Mid ({}) is rejected (port=0, not in BUNDLE), setting to Inactive",
media.mid()
);
}
media.mark_disabled();
media.set_direction(Direction::Inactive);
return;
}
let new_dir = m.direction().invert();
let change_direction_disallowed = !media.remote_created()
&& media.direction() == Direction::Inactive
&& new_dir == Direction::SendOnly;
if change_direction_disallowed {
debug!(
"Ignore attempt to change inactive to recvonly by remote peer for locally created mid: {}",
media.mid()
);
} else {
media.set_direction(new_dir);
}
if new_dir.is_sending() {
let rids = m.rids();
let rid_tx = if rids.is_empty() {
Rids::None
} else {
Rids::Specific(rids)
};
media.set_rid_tx(rid_tx);
}
if new_dir.is_receiving() {
let rids = m.rids();
let rid_rx = if rids.is_empty() {
Rids::Any
} else {
Rids::Specific(rids)
};
media.set_rid_rx(rid_rx);
}
let pts: Vec<Pt> = m
.rtp_params()
.into_iter()
.filter_map(|p| config.sdp_match_remote(p, m.direction()))
.collect();
media.set_remote_pts(pts);
let mut remote_extmap = ExtensionMap::empty();
for (id, ext) in m.extmaps().into_iter() {
let in_session = match exts.lookup(id) {
Some(v) => v,
None => continue,
};
if in_session != ext {
continue;
}
remote_extmap.set(id, in_session.clone());
}
media.set_remote_extmap(remote_extmap);
if !new_dir.is_receiving() {
return;
}
if let Some(s) = m.simulcast() {
if s.is_munged {
warn!("Not supporting simulcast via munging SDP");
} else if media.simulcast().is_none() {
media.set_simulcast(s.invert());
}
}
if m.simulcast().is_some() {
return;
}
let infos = m.ssrc_info();
let main = infos.iter().filter(|i| i.repairs.is_none());
for i in main {
debug!("Adding pre-communicated SSRC: {:?}", i);
let repair_ssrc = infos
.iter()
.find(|r| r.repairs == Some(i.ssrc))
.map(|r| r.ssrc);
let midrid = MidRid(media.mid(), None);
let suppress_nack = repair_ssrc.is_none();
streams.expect_stream_rx(i.ssrc, repair_ssrc, midrid, suppress_nack);
}
}
trait AsSdpMediaLine {
fn mid(&self) -> Mid;
fn msid(&self) -> Option<&Msid>;
fn index(&self) -> usize;
fn kind(&self) -> MediaKind;
fn as_media_line(
&self,
attrs: Vec<MediaAttribute>,
ssrcs_tx: &[(Ssrc, Option<Ssrc>)],
exts: &ExtensionMap,
params: &[PayloadParams],
) -> MediaLine;
}
impl AsSdpMediaLine for (Mid, usize) {
fn mid(&self) -> Mid {
self.0
}
fn msid(&self) -> Option<&Msid> {
None
}
fn index(&self) -> usize {
self.1
}
fn kind(&self) -> MediaKind {
MediaKind::Audio }
fn as_media_line(
&self,
mut attrs: Vec<MediaAttribute>,
_ssrcs_tx: &[(Ssrc, Option<Ssrc>)],
_exts: &ExtensionMap,
_params: &[PayloadParams],
) -> MediaLine {
attrs.push(MediaAttribute::Mid(self.0));
attrs.push(MediaAttribute::SctpPort(5000));
attrs.push(MediaAttribute::MaxMessageSize(262144));
MediaLine {
typ: sdp::MediaType::Application,
disabled: false,
proto: Proto::Sctp,
pts: vec![],
bw: None,
attrs,
}
}
}
impl AsSdpMediaLine for Media {
fn mid(&self) -> Mid {
Media::mid(self)
}
fn msid(&self) -> Option<&Msid> {
Some(Media::msid(self))
}
fn index(&self) -> usize {
Media::index(self)
}
fn kind(&self) -> MediaKind {
Media::kind(self)
}
fn as_media_line(
&self,
mut attrs: Vec<MediaAttribute>,
ssrcs_tx: &[(Ssrc, Option<Ssrc>)],
exts: &ExtensionMap,
params: &[PayloadParams],
) -> MediaLine {
if self.app_tmp {
let app = (self.mid(), self.index());
return app.as_media_line(attrs, ssrcs_tx, exts, params);
}
attrs.push(MediaAttribute::Mid(self.mid()));
let audio = self.kind() == MediaKind::Audio;
for (id, ext) in self.remote_extmap().iter_by_media_type(audio) {
attrs.push(MediaAttribute::ExtMap {
id,
ext: ext.clone(),
});
}
attrs.push(self.direction().into());
attrs.push(MediaAttribute::Msid(self.msid().clone()));
attrs.push(MediaAttribute::RtcpMux);
let effective_params = params.iter().filter(|p| self.remote_pts().contains(&p.pt));
let mut pts = vec![];
for p in effective_params {
p.as_media_attrs(&mut attrs);
pts.push(p.pt());
if let Some(rtx) = p.resend() {
pts.push(rtx);
}
}
if let Some(s) = self.simulcast() {
fn to_rids<'a>(
gs: &'a SimulcastGroups,
direction: &'static str,
) -> impl Iterator<Item = MediaAttribute> + 'a {
gs.iter().map(move |layer| MediaAttribute::Rid {
id: layer.restriction_id.clone(),
direction,
pt: vec![],
restriction: layer.attributes.clone().unwrap_or_default(),
})
}
attrs.extend(to_rids(&s.recv, "recv"));
attrs.extend(to_rids(&s.send, "send"));
attrs.push(MediaAttribute::Simulcast(s.clone()));
}
let msid = format!("{} {}", self.msid().stream_id, self.msid().track_id);
for (ssrc, ssrc_rtx) in ssrcs_tx {
attrs.push(MediaAttribute::Ssrc {
ssrc: *ssrc,
attr: "cname".to_string(),
value: self.cname().to_string(),
});
attrs.push(MediaAttribute::Ssrc {
ssrc: *ssrc,
attr: "msid".to_string(),
value: msid.clone(),
});
if let Some(ssrc_rtx) = ssrc_rtx {
attrs.push(MediaAttribute::Ssrc {
ssrc: *ssrc_rtx,
attr: "cname".to_string(),
value: self.cname().to_string(),
});
attrs.push(MediaAttribute::Ssrc {
ssrc: *ssrc_rtx,
attr: "msid".to_string(),
value: msid.clone(),
});
}
}
for (ssrc, ssrc_rtx) in ssrcs_tx {
if let Some(ssrc_rtx) = ssrc_rtx {
attrs.push(MediaAttribute::SsrcGroup {
semantics: "FID".to_string(),
ssrcs: vec![*ssrc, *ssrc_rtx],
});
}
}
MediaLine {
typ: self.kind().into(),
disabled: self.disabled(),
proto: Proto::Srtp,
pts,
bw: None,
attrs,
}
}
}
impl From<MediaKind> for MediaType {
fn from(value: MediaKind) -> Self {
match value {
MediaKind::Audio => MediaType::Audio,
MediaKind::Video => MediaType::Video,
}
}
}
struct AsSdpParams<'a, 'b> {
pub candidates: Vec<Candidate>,
pub creds: IceCreds,
pub fingerprint: &'a Fingerprint,
pub setup: Setup,
pub pending: Option<&'b Changes>,
pub local_sctp_init: Option<String>,
}
impl<'a, 'b> AsSdpParams<'a, 'b> {
pub fn new(rtc: &'a Rtc, pending: Option<&'b Changes>) -> Self {
let (creds, candidates) = if let Some((new_creds, keep_local_candidates)) =
pending.and_then(|p| p.ice_restart())
{
if keep_local_candidates {
let mut new_candidates = rtc.ice.local_candidates().collect::<Vec<_>>();
for c in &mut new_candidates {
c.set_ufrag(&new_creds.ufrag);
}
(new_creds, new_candidates)
} else {
(new_creds, vec![])
}
} else {
(
rtc.ice.local_credentials().clone(),
rtc.ice.local_candidates().collect::<Vec<_>>(),
)
};
AsSdpParams {
candidates,
creds,
fingerprint: rtc.dtls.local_fingerprint(),
setup: match rtc.dtls.is_active() {
Some(true) => Setup::Active,
Some(false) => Setup::Passive,
None => Setup::ActPass,
},
pending,
local_sctp_init: rtc.sctp.local_sctp_init_for_sdp(),
}
}
fn media_attributes(&self, include_candidates: bool) -> Vec<MediaAttribute> {
use MediaAttribute::*;
let mut v = if include_candidates {
self.candidates
.iter()
.map(|c| Candidate(c.clone()))
.collect()
} else {
vec![]
};
v.push(IceUfrag(self.creds.ufrag.clone()));
v.push(IcePwd(self.creds.pass.clone()));
v.push(IceOptions("trickle".into()));
v.push(Fingerprint(self.fingerprint.clone()));
v.push(Setup(self.setup));
v
}
}
impl fmt::Debug for SdpPendingOffer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SdpPendingOffer").finish()
}
}
impl Changes {
pub fn contains_add_app(&self) -> bool {
for i in 0..self.0.len() {
if matches!(&self.0[i], Change::AddApp(_)) {
return true;
}
}
false
}
pub fn take_new_channels(&mut self) -> Vec<(ChannelId, ChannelConfig)> {
let mut v = vec![];
if self.0.is_empty() {
return v;
}
for i in (0..self.0.len()).rev() {
if matches!(&self.0[i], Change::AddChannel(_)) {
if let Change::AddChannel(id) = self.0.remove(i) {
v.push(id);
}
}
}
v
}
fn ensure_correct_answer(&self, lines: &[&MediaLine]) -> Option<String> {
if self.count_new_medias() != lines.len() {
return Some(format!(
"Differing m-line count in offer vs answer: {} != {}",
self.count_new_medias(),
lines.len()
));
}
'next: for l in lines {
let mid = l.mid();
for m in &self.0 {
use Change::*;
match m {
AddMedia(v) if v.mid == mid => {
if !l.typ.is_media() {
return Some(format!(
"Answer m-line for mid ({}) is not of media type: {:?}",
mid, l.typ
));
}
continue 'next;
}
AddApp(v) if *v == mid => {
if !l.typ.is_channel() {
return Some(format!(
"Answer m-line for mid ({}) is not a data channel: {:?}",
mid, l.typ
));
}
continue 'next;
}
_ => {}
}
}
return Some(format!("Mid in answer is not in offer: {mid}"));
}
None
}
fn count_new_medias(&self) -> usize {
self.0
.iter()
.filter(|c| matches!(c, Change::AddMedia(_) | Change::AddApp(_)))
.count()
}
pub fn as_new_medias<'a, 'b: 'a>(
&'a self,
index_start: usize,
config: &'b CodecConfig,
exts: &'b ExtensionMap,
) -> impl Iterator<Item = Media> + 'a {
let mut media_idx = 0usize;
self.0.iter().filter_map(move |c| {
let result = c.as_new_media(index_start + media_idx, config, exts);
if result.is_some() {
media_idx += 1;
}
result
})
}
pub(crate) fn apply_to(&self, lines: &mut [MediaLine]) {
for change in &self.0 {
if let Change::Direction(mid, dir) = change {
if let Some(line) = lines.iter_mut().find(|l| l.mid() == *mid) {
if let Some(dir_pos) = line.attrs.iter().position(|a| a.is_direction()) {
line.attrs[dir_pos] = (*dir).into();
}
}
}
}
}
fn ssrcs_for_mid(&self, mid: Mid) -> &[(Ssrc, Option<Ssrc>)] {
let maybe_add_media = self
.0
.iter()
.filter_map(|c| {
if let Change::AddMedia(m) = c {
Some(m)
} else {
None
}
})
.find(|m| m.mid == mid);
let Some(m) = maybe_add_media else {
return &[];
};
&m.ssrcs
}
}
impl Change {
fn as_new_media(
&self,
index: usize,
config: &CodecConfig,
exts: &ExtensionMap,
) -> Option<Media> {
use Change::*;
match self {
AddMedia(v) => {
let mut add = v.clone();
add.pts = config.all_for_kind(v.kind).map(|p| p.pt()).collect();
add.exts = exts.cloned_with_type(v.kind.is_audio());
add.index = index;
Some(Media::from_add_media(add))
}
AddApp(mid) => Some(Media::from_app_tmp(*mid, index)),
_ => None,
}
}
}
#[cfg(test)]
mod test {
use std::time::Instant;
use sdp::RestrictionId;
use sdp::SimulcastLayer as SdpSimulcastLayer;
use crate::format::Codec;
use crate::media::{Simulcast, SimulcastLayer};
use crate::sdp::RtpMap;
use super::*;
fn resolve_pt(m_line: &MediaLine, needle: Pt) -> RtpMap {
m_line
.attrs
.iter()
.find_map(|attr| match attr {
MediaAttribute::RtpMap { pt, value } if *pt == needle => Some(*value),
_ => None,
})
.unwrap_or_else(|| panic!("Expected to find RtpMap for {needle}"))
}
fn count_lines(lines: &str, what: &str) -> usize {
lines.lines().filter(|l| l == &what).count()
}
#[test]
fn test_out_of_order_error() {
crate::init_crypto_default();
let now = Instant::now();
let mut rtc1 = Rtc::new(now);
let mut rtc2 = Rtc::new(now);
let mut change1 = rtc1.sdp_api();
change1.add_channel("ch1".into());
let (offer1, pending1) = change1.apply().unwrap();
let mut change2 = rtc2.sdp_api();
change2.add_channel("ch2".into());
let (offer2, _) = change2.apply().unwrap();
let _ = rtc1.sdp_api().accept_offer(offer2).unwrap();
let answer2 = rtc2.sdp_api().accept_offer(offer1).unwrap();
let r = rtc1.sdp_api().accept_answer(pending1, answer2);
assert!(matches!(r, Err(RtcError::ChangesOutOfOrder)));
}
#[test]
fn sdp_api_merge_works() {
crate::init_crypto_default();
let mut rtc = Rtc::new(Instant::now());
let mut changes = rtc.sdp_api();
changes.add_media(MediaKind::Audio, Direction::SendOnly, None, None, None);
let (offer, pending) = changes.apply().unwrap();
let mut changes = rtc.sdp_api();
changes.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
changes.merge(pending);
let (new_offer, _) = changes.apply().unwrap();
assert_eq!(offer.media_lines[0], new_offer.media_lines[0]);
assert_eq!(new_offer.media_lines[0].typ, MediaType::Audio);
assert_eq!(new_offer.media_lines[1].typ, MediaType::Video);
assert_eq!(new_offer.media_lines.len(), 2);
}
#[test]
fn as_new_medias_contiguous_indices_with_non_media_changes() {
crate::init_crypto_default();
let now = Instant::now();
let mut rtc = Rtc::new(now);
let mut changes = Changes::default();
let mid_app = rtc.new_mid();
changes.0.push(Change::AddApp(mid_app));
let mid_audio = rtc.new_mid();
let chan_id = rtc.chan.new_channel(&ChannelConfig {
label: "ch".into(),
..Default::default()
});
changes.0.push(Change::AddChannel((
chan_id,
ChannelConfig {
label: "ch".into(),
..Default::default()
},
)));
changes.0.push(Change::AddMedia(AddMedia {
mid: mid_audio,
cname: "test".into(),
msid: Msid {
stream_id: "stream".into(),
track_id: "track".into(),
},
kind: MediaKind::Audio,
dir: Direction::SendOnly,
ssrcs: vec![],
simulcast: None,
pts: vec![],
exts: ExtensionMap::empty(),
index: 0, }));
let config = CodecConfig::new_with_defaults();
let exts = ExtensionMap::standard();
let medias: Vec<Media> = changes.as_new_medias(0, &config, &exts).collect();
assert_eq!(medias.len(), 2, "Expected 2 media-producing entries");
assert_eq!(medias[0].index(), 0, "AddApp should have index 0");
assert_eq!(
medias[1].index(),
1,
"AddMedia(audio) should have contiguous index 1, not 2"
);
}
#[test]
fn sdp_api_merge_stale_media_keeps_original_position() {
crate::init_crypto_default();
let mut rtc = Rtc::new(Instant::now());
let mut changes = rtc.sdp_api();
let _mid_audio = changes.add_media(MediaKind::Audio, Direction::SendOnly, None, None, None);
let (offer1, pending) = changes.apply().unwrap();
assert_eq!(offer1.media_lines[0].typ, MediaType::Audio);
let mut changes = rtc.sdp_api();
changes.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
changes.merge(pending);
let (offer2, _) = changes.apply().unwrap();
assert_eq!(
offer2.media_lines[0].typ,
MediaType::Audio,
"Original audio from pending should be at position 0 (same as original offer)"
);
assert_eq!(
offer2.media_lines[1].typ,
MediaType::Video,
"New video added to current SdpApi should be at position 1"
);
assert_eq!(
offer1.media_lines[0], offer2.media_lines[0],
"Audio m-line must be identical between original and merged offers"
);
}
#[test]
fn sdp_api_merge_with_direction_and_new_media_preserves_positions() {
crate::init_crypto_default();
let now = Instant::now();
let mut rtc1 = Rtc::new(now);
let mut rtc2 = Rtc::new(now);
let mut changes = rtc1.sdp_api();
let mid_audio = changes.add_media(MediaKind::Audio, Direction::SendOnly, None, None, None);
let (offer0, pending0) = changes.apply().unwrap();
let answer0 = rtc2.sdp_api().accept_offer(offer0).unwrap();
rtc1.sdp_api().accept_answer(pending0, answer0).unwrap();
let mut changes = rtc1.sdp_api();
let mid_v1 = changes.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
let mid_v2 = changes.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
let (offer1, pending1) = changes.apply().unwrap();
assert_eq!(offer1.media_lines[1].mid(), mid_v1, "video1 at idx 1");
assert_eq!(offer1.media_lines[2].mid(), mid_v2, "video2 at idx 2");
let mut changes = rtc1.sdp_api();
changes.set_direction(mid_audio, Direction::Inactive);
let mid_screen = changes.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
changes.merge(pending1);
let (offer2, _) = changes.apply().unwrap();
assert_eq!(
offer2.media_lines.len(),
4,
"Merged offer should have 4 m-lines: audio + video1 + video2 + screen"
);
assert_eq!(
offer2.media_lines[1].mid(),
mid_v1,
"video1 from pending should remain at position 1"
);
assert_eq!(
offer1.media_lines[1], offer2.media_lines[1],
"video1 m-line should be identical in both offers"
);
assert_eq!(
offer2.media_lines[2].mid(),
mid_v2,
"video2 from pending should remain at position 2"
);
assert_eq!(
offer1.media_lines[2], offer2.media_lines[2],
"video2 m-line should be identical in both offers"
);
assert_eq!(
offer2.media_lines[3].mid(),
mid_screen,
"New screen-share should be at position 3"
);
}
#[test]
fn test_rtp_payload_priority() {
crate::init_crypto_default();
let now = Instant::now();
let mut rtc1 = Rtc::builder()
.clear_codecs()
.enable_h264(true)
.enable_vp8(true)
.enable_vp9(true)
.build(now);
let mut rtc2 = Rtc::builder()
.clear_codecs()
.enable_vp8(true)
.enable_h264(true)
.build(now);
let mut change1 = rtc1.sdp_api();
change1.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
let (offer1, _) = change1.apply().unwrap();
let answer = rtc2.sdp_api().accept_offer(offer1).unwrap();
assert_eq!(
answer.media_lines.len(),
1,
"There should be one mline only"
);
let first_mline = &answer.media_lines[0];
let first_pt = resolve_pt(first_mline, first_mline.pts[0]);
assert_eq!(
first_pt.codec,
Codec::Vp8,
"The first PT returned should be the highest priority PT from the answer that is supported."
);
let vp9_unsupported = first_mline
.pts
.iter()
.any(|pt| resolve_pt(first_mline, *pt).codec == Codec::Vp9);
assert!(
!vp9_unsupported,
"VP9 was not offered, so it should not be present in the answer"
);
}
#[test]
fn non_simulcast_rids() {
crate::init_crypto_default();
let now = Instant::now();
let mut rtc1 = Rtc::new(now);
let mut rtc2 = Rtc::new(now);
let mid = {
let mut changes = rtc1.sdp_api();
let mid = changes.add_media(MediaKind::Audio, Direction::SendOnly, None, None, None);
let (offer, pending) = changes.apply().unwrap();
let answer = rtc2.sdp_api().accept_offer(offer).unwrap();
rtc1.sdp_api().accept_answer(pending, answer).unwrap();
assert!(matches!(rtc1.media(mid).unwrap().rids_rx(), Rids::Any));
assert!(matches!(rtc1.media(mid).unwrap().rids_tx(), Rids::None));
assert!(matches!(rtc2.media(mid).unwrap().rids_rx(), Rids::Any));
assert!(matches!(rtc2.media(mid).unwrap().rids_tx(), Rids::None));
mid
};
{
let mut changes = rtc1.sdp_api();
changes.set_direction(mid, Direction::Inactive);
let (offer, pending) = changes.apply().unwrap();
let answer = rtc2.sdp_api().accept_offer(offer).unwrap();
rtc1.sdp_api().accept_answer(pending, answer).unwrap();
assert!(matches!(rtc1.media(mid).unwrap().rids_rx(), Rids::Any));
assert!(matches!(rtc1.media(mid).unwrap().rids_tx(), Rids::None));
assert!(matches!(rtc2.media(mid).unwrap().rids_rx(), Rids::Any));
assert!(matches!(rtc2.media(mid).unwrap().rids_tx(), Rids::None));
}
}
#[test]
fn simulcast_ssrc_allocation() {
crate::init_crypto_default();
let mut rtc1 = Rtc::new(Instant::now());
let mut simulcast = Simulcast::new();
simulcast.add_send_layer(SimulcastLayer::new("h"));
simulcast.add_send_layer(SimulcastLayer::new("m"));
simulcast.add_send_layer(SimulcastLayer::new("l"));
let mut change = rtc1.sdp_api();
change.add_media(
MediaKind::Video,
Direction::SendOnly,
None,
None,
Some(simulcast),
);
let Change::AddMedia(am) = &change.changes[0] else {
panic!("Not AddMedia?!");
};
let pending_ssrcs = am.ssrcs.clone();
assert_eq!(pending_ssrcs.len(), 3);
for p in &pending_ssrcs {
assert!(p.1.is_some()); }
let (offer, _) = change.apply().unwrap();
let sdp = offer.into_inner();
let line = &sdp.media_lines[0];
assert_eq!(
line.simulcast().unwrap().send,
SimulcastGroups(vec![
SdpSimulcastLayer {
restriction_id: RestrictionId("h".into(), true),
attributes: None,
},
SdpSimulcastLayer {
restriction_id: RestrictionId("m".into(), true),
attributes: None,
},
SdpSimulcastLayer {
restriction_id: RestrictionId("l".into(), true),
attributes: None,
},
])
);
assert_eq!(line.ssrc_info().len(), pending_ssrcs.len() * 2);
let fids: Vec<_> = line
.attrs
.iter()
.filter_map(|a| {
if let MediaAttribute::SsrcGroup { semantics, ssrcs } = a {
assert_eq!(semantics, "FID");
assert_eq!(ssrcs.len(), 2);
Some((ssrcs[0], ssrcs[1]))
} else {
None
}
})
.collect();
assert_eq!(fids.len(), pending_ssrcs.len());
for (a, b) in fids.iter().zip(pending_ssrcs.iter()) {
assert_eq!(a.0, b.0);
assert_eq!(Some(a.1), b.1);
}
let line_string = line.to_string();
assert_eq!(count_lines(&line_string, "a=rid:h send"), 1);
assert_eq!(count_lines(&line_string, "a=rid:m send"), 1);
assert_eq!(count_lines(&line_string, "a=rid:l send"), 1);
}
#[test]
fn simulcast_attributes() {
crate::init_crypto_default();
let mut rtc1 = Rtc::new(Instant::now());
let mut simulcast = Simulcast::new();
simulcast.add_send_layer(
SimulcastLayer::new_with_attributes("high")
.max_width(1280)
.max_height(720)
.max_br(1100000)
.max_br(1300000)
.max_br(1500000) .max_fps(30)
.build(),
);
simulcast.add_send_layer(
SimulcastLayer::new_with_attributes("medium")
.max_width(640)
.max_height(360)
.max_br(600000)
.build(),
);
simulcast.add_send_layer(
SimulcastLayer::new_with_attributes("low")
.max_height(180)
.max_br(200000)
.max_fps(15)
.build(),
);
simulcast.add_send_layer(
SimulcastLayer::new_with_attributes("custom")
.custom("foo", "bar")
.build(),
);
simulcast.add_send_layer(SimulcastLayer::new_with_attributes("no_attrs").build());
let mut change = rtc1.sdp_api();
change.add_media(
MediaKind::Video,
Direction::SendOnly,
None,
None,
Some(simulcast),
);
let (offer, _) = change.apply().unwrap();
let sdp = offer.into_inner();
let line = &sdp.media_lines[0];
fn pairs_to_some_vec(pairs: &[(&str, &str)]) -> Option<Vec<(String, String)>> {
Some(
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
)
}
assert_eq!(
line.simulcast().unwrap().send,
SimulcastGroups(vec![
SdpSimulcastLayer {
restriction_id: RestrictionId("high".into(), true),
attributes: pairs_to_some_vec(&[
("max-width", "1280"),
("max-height", "720"),
("max-br", "1500000"),
("max-fps", "30"),
]),
},
SdpSimulcastLayer {
restriction_id: RestrictionId("medium".into(), true),
attributes: pairs_to_some_vec(&[
("max-width", "640"),
("max-height", "360"),
("max-br", "600000"),
]),
},
SdpSimulcastLayer {
restriction_id: RestrictionId("low".into(), true),
attributes: pairs_to_some_vec(&[
("max-height", "180"),
("max-br", "200000"),
("max-fps", "15"),
]),
},
SdpSimulcastLayer {
restriction_id: RestrictionId("custom".into(), true),
attributes: pairs_to_some_vec(&[("foo", "bar"),]),
},
SdpSimulcastLayer {
restriction_id: RestrictionId("no_attrs".into(), true),
attributes: None,
},
])
);
let line_string = line.to_string();
assert_eq!(
count_lines(
&line_string,
"a=rid:high send max-width=1280;max-height=720;max-br=1500000;max-fps=30"
),
1
);
assert_eq!(
count_lines(
&line_string,
"a=rid:medium send max-width=640;max-height=360;max-br=600000"
),
1
);
assert_eq!(
count_lines(
&line_string,
"a=rid:low send max-height=180;max-br=200000;max-fps=15"
),
1
);
assert_eq!(count_lines(&line_string, "a=rid:custom send foo=bar"), 1);
assert_eq!(count_lines(&line_string, "a=rid:no_attrs send"), 1);
}
}