From 2ea67b30704ea5da3abac19acfc70eb722b6bb61 Mon Sep 17 00:00:00 2001 From: vlxdisluv Date: Tue, 23 Jun 2026 01:11:44 +0500 Subject: [PATCH] feat(sdp): parse media direction attribute Parse the SDP direction attribute (sendrecv/sendonly/recvonly/inactive) and expose it on MediaDesc. Resolve media-level over session-level per RFC 3264, defaulting to sendrecv. Enables call-hold detection. --- sdp/offer.go | 20 ++++++++++++++ sdp/offer_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/sdp/offer.go b/sdp/offer.go index 46818a3..492be90 100644 --- a/sdp/offer.go +++ b/sdp/offer.go @@ -100,6 +100,7 @@ type MediaDesc struct { Codecs []CodecInfo DTMFType byte // set to 0 if there's no DTMF CryptoProfiles []srtp.Profile + Direction sdp.Direction } func appendCryptoProfiles(attrs []sdp.Attribute, profiles []srtp.Profile) []sdp.Attribute { @@ -450,6 +451,19 @@ func ParseWith(s *media.CodecSet, data []byte) (*Description, error) { return nil, err } offer.MediaDesc = *m + + dir := sdp.DirectionSendRecv + for _, key := range []string{"sendrecv", "sendonly", "recvonly", "inactive"} { + if _, ok := offer.SDP.Attribute(key); ok { + dir, _ = sdp.NewDirection(key) + break + } + } + if m.Direction != 0 { + dir = m.Direction + } + offer.MediaDesc.Direction = dir + return offer, nil } @@ -651,6 +665,12 @@ func ParseMediaWith(s *media.CodecSet, d *sdp.MediaDescription) (*MediaDesc, err continue } out.CryptoProfiles = append(out.CryptoProfiles, *p) + case "sendrecv", "sendonly", "recvonly", "inactive": + dir, err := sdp.NewDirection(m.Key) + if err != nil { + continue + } + out.Direction = dir } } for _, f := range d.MediaName.Formats { diff --git a/sdp/offer_test.go b/sdp/offer_test.go index ff6cfec..3a6fb64 100644 --- a/sdp/offer_test.go +++ b/sdp/offer_test.go @@ -813,3 +813,69 @@ func TestSRTPIntegration(t *testing.T) { packetMKI := extractMKIFromSRTPPacket(offerCaptured[0], len(offerMKI), authTagSize) require.Equal(t, offerMKI, packetMKI, "MKI %v does not match expected value %v", packetMKI, offerMKI) } + +func TestParseDirection(t *testing.T) { + g := media.GlobalCodecs() + + const head = `v=0 +o=- 1 1 IN IP4 203.0.113.5 +s=LiveKit +c=IN IP4 203.0.113.5 +t=0 0 +` + + tests := []struct { + name string + sdp string + want sdp.Direction + }{ + { + name: "media-level sendonly (hold)", + sdp: head + `m=audio 10000 RTP/AVP 0 +a=rtpmap:0 PCMU/8000 +a=sendonly +`, + want: sdp.DirectionSendOnly, + }, + { + name: "no attribute defaults to sendrecv", + sdp: head + `m=audio 10000 RTP/AVP 0 +a=rtpmap:0 PCMU/8000 +`, + want: sdp.DirectionSendRecv, + }, + { + name: "session-level applies when media omits", + sdp: head + `a=sendonly +m=audio 10000 RTP/AVP 0 +a=rtpmap:0 PCMU/8000 +`, + want: sdp.DirectionSendOnly, + }, + { + name: "media overrides session", + sdp: head + `a=inactive +m=audio 10000 RTP/AVP 0 +a=rtpmap:0 PCMU/8000 +a=sendonly +`, + want: sdp.DirectionSendOnly, + }, + { + name: "inactive media level", + sdp: head + `m=audio 10000 RTP/AVP 0 +a=rtpmap:0 PCMU/8000 +a=inactive +`, + want: sdp.DirectionInactive, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + d, err := ParseWith(g, []byte(test.sdp)) + require.NoError(t, err) + require.Equal(t, test.want, d.Direction) + }) + } +}