allow setting additional properties of streams through description.Stream

This commit is contained in:
aler9
2023-08-16 19:02:49 +02:00
committed by Alessandro Ros
parent 4e000eb2dd
commit cdbecb1f5d
54 changed files with 943 additions and 893 deletions

272
pkg/description/media.go Normal file
View File

@@ -0,0 +1,272 @@
// Package description contains objects to describe streams.
package description
import (
"fmt"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
psdp "github.com/pion/sdp/v3"
"github.com/bluenviron/gortsplib/v4/pkg/format"
"github.com/bluenviron/gortsplib/v4/pkg/url"
)
var smartRegexp = regexp.MustCompile("^([0-9]+) (.*?)/90000")
func getControlAttribute(attributes []psdp.Attribute) string {
for _, attr := range attributes {
if attr.Key == "control" {
return attr.Value
}
}
return ""
}
func getDirection(attributes []psdp.Attribute) MediaDirection {
for _, attr := range attributes {
switch attr.Key {
case "sendonly":
return MediaDirectionSendonly
case "recvonly":
return MediaDirectionRecvonly
case "sendrecv":
return MediaDirectionSendrecv
}
}
return ""
}
func getFormatAttribute(attributes []psdp.Attribute, payloadType uint8, key string) string {
for _, attr := range attributes {
if attr.Key == key {
v := strings.TrimSpace(attr.Value)
if parts := strings.SplitN(v, " ", 2); len(parts) == 2 {
if tmp, err := strconv.ParseUint(parts[0], 10, 8); err == nil && uint8(tmp) == payloadType {
return parts[1]
}
}
}
}
return ""
}
func decodeFMTP(enc string) map[string]string {
if enc == "" {
return nil
}
ret := make(map[string]string)
for _, kv := range strings.Split(enc, ";") {
kv = strings.Trim(kv, " ")
if len(kv) == 0 {
continue
}
tmp := strings.SplitN(kv, "=", 2)
if len(tmp) != 2 {
continue
}
ret[strings.ToLower(tmp[0])] = tmp[1]
}
return ret
}
func sortedKeys(fmtp map[string]string) []string {
keys := make([]string, len(fmtp))
i := 0
for key := range fmtp {
keys[i] = key
i++
}
sort.Strings(keys)
return keys
}
// MediaDirection is the direction of a media stream.
type MediaDirection string
// standard directions.
const (
MediaDirectionSendonly MediaDirection = "sendonly"
MediaDirectionRecvonly MediaDirection = "recvonly"
MediaDirectionSendrecv MediaDirection = "sendrecv"
)
// MediaType is the type of a media stream.
type MediaType string
// standard media stream types.
const (
MediaTypeVideo MediaType = "video"
MediaTypeAudio MediaType = "audio"
MediaTypeApplication MediaType = "application"
)
// Media is a media stream.
// It contains one or more formats.
type Media struct {
// Media type.
Type MediaType
// Direction of the stream.
Direction MediaDirection
// Control attribute.
Control string
// Formats contained into the media.
Formats []format.Format
}
// Unmarshal decodes the media from the SDP format.
func (m *Media) Unmarshal(md *psdp.MediaDescription) error {
m.Type = MediaType(md.MediaName.Media)
m.Direction = getDirection(md.Attributes)
m.Control = getControlAttribute(md.Attributes)
m.Formats = nil
for _, payloadType := range md.MediaName.Formats {
if payloadType == "smart/1/90000" {
for _, attr := range md.Attributes {
if attr.Key == "rtpmap" {
sm := smartRegexp.FindStringSubmatch(attr.Value)
if sm != nil {
payloadType = sm[1]
break
}
}
}
}
tmp, err := strconv.ParseUint(payloadType, 10, 8)
if err != nil {
return err
}
payloadTypeInt := uint8(tmp)
rtpMap := getFormatAttribute(md.Attributes, payloadTypeInt, "rtpmap")
fmtp := decodeFMTP(getFormatAttribute(md.Attributes, payloadTypeInt, "fmtp"))
format, err := format.Unmarshal(string(m.Type), payloadTypeInt, rtpMap, fmtp)
if err != nil {
return err
}
m.Formats = append(m.Formats, format)
}
if m.Formats == nil {
return fmt.Errorf("no formats found")
}
return nil
}
// Marshal encodes the media in SDP format.
func (m Media) Marshal() *psdp.MediaDescription {
md := &psdp.MediaDescription{
MediaName: psdp.MediaName{
Media: string(m.Type),
Protos: []string{"RTP", "AVP"},
},
Attributes: []psdp.Attribute{
{
Key: "control",
Value: m.Control,
},
},
}
if m.Direction != "" {
md.Attributes = append(md.Attributes, psdp.Attribute{
Key: string(m.Direction),
})
}
for _, forma := range m.Formats {
typ := strconv.FormatUint(uint64(forma.PayloadType()), 10)
md.MediaName.Formats = append(md.MediaName.Formats, typ)
rtpmap := forma.RTPMap()
if rtpmap != "" {
md.Attributes = append(md.Attributes, psdp.Attribute{
Key: "rtpmap",
Value: typ + " " + rtpmap,
})
}
fmtp := forma.FMTP()
if len(fmtp) != 0 {
tmp := make([]string, len(fmtp))
for i, key := range sortedKeys(fmtp) {
tmp[i] = key + "=" + fmtp[key]
}
md.Attributes = append(md.Attributes, psdp.Attribute{
Key: "fmtp",
Value: typ + " " + strings.Join(tmp, "; "),
})
}
}
return md
}
// URL returns the absolute URL of the media.
func (m Media) URL(contentBase *url.URL) (*url.URL, error) {
if contentBase == nil {
return nil, fmt.Errorf("Content-Base header not provided")
}
// no control attribute, use base URL
if m.Control == "" {
return contentBase, nil
}
// control attribute contains an absolute path
if strings.HasPrefix(m.Control, "rtsp://") ||
strings.HasPrefix(m.Control, "rtsps://") {
ur, err := url.Parse(m.Control)
if err != nil {
return nil, err
}
// copy host and credentials
ur.Host = contentBase.Host
ur.User = contentBase.User
return ur, nil
}
// control attribute contains a relative control attribute
// insert the control attribute at the end of the URL
// if there's a query, insert it after the query
// otherwise insert it after the path
strURL := contentBase.String()
if m.Control[0] != '?' && !strings.HasSuffix(strURL, "/") {
strURL += "/"
}
ur, _ := url.Parse(strURL + m.Control)
return ur, nil
}
// FindFormat finds a certain format among all the formats in the media.
func (m Media) FindFormat(forma interface{}) bool {
for _, formak := range m.Formats {
if reflect.TypeOf(formak) == reflect.TypeOf(forma).Elem() {
reflect.ValueOf(forma).Elem().Set(reflect.ValueOf(formak))
return true
}
}
return false
}

View File

@@ -0,0 +1,154 @@
package description
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/bluenviron/gortsplib/v4/pkg/format"
"github.com/bluenviron/gortsplib/v4/pkg/sdp"
"github.com/bluenviron/gortsplib/v4/pkg/url"
)
func mustParseURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return u
}
func TestMediaURL(t *testing.T) {
for _, ca := range []struct {
name string
sdp []byte
baseURL *url.URL
ur *url.URL
}{
{
"missing control",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/path/"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/path/"),
},
{
"absolute control",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:rtsp://localhost/path/trackID=7"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/path/trackID=7"),
},
{
"absolute control rtsps",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:rtsps://localhost/path/trackID=7"),
mustParseURL("rtsps://myuser:mypass@192.168.1.99:554/"),
mustParseURL("rtsps://myuser:mypass@192.168.1.99:554/path/trackID=7"),
},
{
"relative control",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:trackID=5"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/path/"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/path/trackID=5"),
},
{
"relative control, subpath",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:trackID=5"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/sub/path/"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/sub/path/trackID=5"),
},
{
"relative control, subpath, without slash",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:trackID=5"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/sub/path"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/sub/path/trackID=5"),
},
{
"relative control, url with query",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:trackID=5"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/" +
"test?user=tmp&password=BagRep1&channel=1&stream=0.sdp"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/" +
"test?user=tmp&password=BagRep1&channel=1&stream=0.sdp/trackID=5"),
},
{
"relative control, url with special chars and query",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:trackID=5"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/" +
"te!st?user=tmp&password=BagRep1!&channel=1&stream=0.sdp"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/" +
"te!st?user=tmp&password=BagRep1!&channel=1&stream=0.sdp/trackID=5"),
},
{
"relative control, url with query without question mark",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:trackID=5"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/user=tmp&password=BagRep1!&channel=1&stream=0.sdp"),
mustParseURL("rtsp://myuser:mypass@192.168.1.99:554/user=tmp&password=BagRep1!&channel=1&stream=0.sdp/trackID=5"),
},
{
"relative control, control is query",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:?ctype=video"),
mustParseURL("rtsp://192.168.1.99:554/test"),
mustParseURL("rtsp://192.168.1.99:554/test?ctype=video"),
},
{
"relative control, control is query and no path",
[]byte("v=0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=control:?ctype=video"),
mustParseURL("rtsp://192.168.1.99:554/"),
mustParseURL("rtsp://192.168.1.99:554/?ctype=video"),
},
} {
t.Run(ca.name, func(t *testing.T) {
var sd sdp.SessionDescription
err := sd.Unmarshal(ca.sdp)
require.NoError(t, err)
var media Media
err = media.Unmarshal(sd.MediaDescriptions[0])
require.NoError(t, err)
ur, err := media.URL(ca.baseURL)
require.NoError(t, err)
require.Equal(t, ca.ur, ur)
})
}
}
func TestMediaURLError(t *testing.T) {
media := &Media{
Type: "video",
Formats: []format.Format{&format.H264{}},
}
_, err := media.URL(nil)
require.EqualError(t, err, "Content-Base header not provided")
}

View File

@@ -0,0 +1,83 @@
package description
import (
"fmt"
psdp "github.com/pion/sdp/v3"
"github.com/bluenviron/gortsplib/v4/pkg/sdp"
"github.com/bluenviron/gortsplib/v4/pkg/url"
)
// Session is the description of a RTSP session.
type Session struct {
// base URL of the stream (read only).
BaseURL *url.URL
// available media streams.
Medias []*Media
}
// FindFormat finds a certain format among all the formats in all the medias of the stream.
// If the format is found, it is inserted into forma, and its media is returned.
func (d *Session) FindFormat(forma interface{}) *Media {
for _, media := range d.Medias {
ok := media.FindFormat(forma)
if ok {
return media
}
}
return nil
}
// Unmarshal decodes the description from SDP.
func (d *Session) Unmarshal(ssd *sdp.SessionDescription) error {
d.Medias = make([]*Media, len(ssd.MediaDescriptions))
for i, md := range ssd.MediaDescriptions {
var m Media
err := m.Unmarshal(md)
if err != nil {
return fmt.Errorf("media %d is invalid: %v", i+1, err)
}
d.Medias[i] = &m
}
return nil
}
// Marshal encodes the description in SDP.
func (d Session) Marshal(multicast bool) ([]byte, error) {
var address string
if multicast {
address = "224.1.0.0"
} else {
address = "0.0.0.0"
}
sout := &sdp.SessionDescription{
SessionName: psdp.SessionName("Session"),
Origin: psdp.Origin{
Username: "-",
NetworkType: "IN",
AddressType: "IP4",
UnicastAddress: "127.0.0.1",
},
// required by Darwin Sessioning Server
ConnectionInformation: &psdp.ConnectionInformation{
NetworkType: "IN",
AddressType: "IP4",
Address: &psdp.Address{Address: address},
},
TimeDescriptions: []psdp.TimeDescription{
{Timing: psdp.Timing{StartTime: 0, StopTime: 0}},
},
MediaDescriptions: make([]*psdp.MediaDescription, len(d.Medias)),
}
for i, media := range d.Medias {
sout.MediaDescriptions[i] = media.Marshal()
}
return sout.Marshal()
}

View File

@@ -0,0 +1,724 @@
package description
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/bluenviron/gortsplib/v4/pkg/format"
"github.com/bluenviron/gortsplib/v4/pkg/sdp"
)
var casesSession = []struct {
name string
in string
out string
desc Session
}{
{
"one format for each media, absolute",
"v=0\r\n" +
"o=- 0 0 IN IP4 10.0.0.131\r\n" +
"s=Media Presentation\r\n" +
"i=samsung\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"b=AS:2632\r\n" +
"t=0 0\r\n" +
"a=control:rtsp://10.0.100.50/profile5/media.smp\r\n" +
"a=range:npt=now-\r\n" +
"m=video 42504 RTP/AVP 97\r\n" +
"b=AS:2560\r\n" +
"a=rtpmap:97 H264/90000\r\n" +
"a=control:rtsp://10.0.100.50/profile5/media.smp/trackID=v\r\n" +
"a=cliprect:0,0,1080,1920\r\n" +
"a=framesize:97 1920-1080\r\n" +
"a=framerate:30.0\r\n" +
"a=fmtp:97 packetization-mode=1;profile-level-id=640028;sprop-parameter-sets=Z2QAKKy0A8ARPyo=,aO4Bniw=\r\n" +
"m=audio 42506 RTP/AVP 0\r\n" +
"b=AS:64\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"a=control:rtsp://10.0.100.50/profile5/media.smp/trackID=a\r\n" +
"a=recvonly\r\n" +
"m=application 42508 RTP/AVP 107\r\n" +
"b=AS:8\r\n",
"v=0\r\n" +
"o=- 0 0 IN IP4 127.0.0.1\r\n" +
"s=Session\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"t=0 0\r\n" +
"m=video 0 RTP/AVP 97\r\n" +
"a=control:rtsp://10.0.100.50/profile5/media.smp/trackID=v\r\n" +
"a=rtpmap:97 H264/90000\r\n" +
"a=fmtp:97 packetization-mode=1; profile-level-id=640028; sprop-parameter-sets=Z2QAKKy0A8ARPyo=,aO4Bniw=\r\n" +
"m=audio 0 RTP/AVP 0\r\n" +
"a=control:rtsp://10.0.100.50/profile5/media.smp/trackID=a\r\n" +
"a=recvonly\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"m=application 0 RTP/AVP 107\r\n" +
"a=control\r\n",
Session{
Medias: []*Media{
{
Type: "video",
Control: "rtsp://10.0.100.50/profile5/media.smp/trackID=v",
Formats: []format.Format{&format.H264{
PayloadTyp: 97,
PacketizationMode: 1,
SPS: []byte{0x67, 0x64, 0x00, 0x28, 0xac, 0xb4, 0x03, 0xc0, 0x11, 0x3f, 0x2a},
PPS: []byte{0x68, 0xee, 0x01, 0x9e, 0x2c},
}},
},
{
Type: "audio",
Direction: MediaDirectionRecvonly,
Control: "rtsp://10.0.100.50/profile5/media.smp/trackID=a",
Formats: []format.Format{&format.G711{
MULaw: true,
}},
},
{
Type: "application",
Formats: []format.Format{&format.Generic{
PayloadTyp: 107,
}},
},
},
},
},
{
"one format for each media, relative",
"v=0\r\n" +
"o=- 0 0 IN IP4 10.0.0.131\r\n" +
"s=Media Presentation\r\n" +
"i=samsung\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"b=AS:2632\r\n" +
"t=0 0\r\n" +
"a=range:npt=now-\r\n" +
"m=video 42504 RTP/AVP 97\r\n" +
"b=AS:2560\r\n" +
"a=rtpmap:97 H264/90000\r\n" +
"a=control:trackID=1\r\n" +
"a=cliprect:0,0,1080,1920\r\n" +
"a=framesize:97 1920-1080\r\n" +
"a=framerate:30.0\r\n" +
"a=fmtp:97 packetization-mode=1;profile-level-id=640028;sprop-parameter-sets=Z2QAKKy0A8ARPyo=,aO4Bniw=\r\n" +
"m=audio 42506 RTP/AVP 0\r\n" +
"b=AS:64\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"a=control:trackID=2\r\n" +
"a=recvonly\r\n" +
"m=application 42508 RTP/AVP 107\r\n" +
"b=AS:8\r\n",
"v=0\r\n" +
"o=- 0 0 IN IP4 127.0.0.1\r\n" +
"s=Session\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"t=0 0\r\n" +
"m=video 0 RTP/AVP 97\r\n" +
"a=control:trackID=1\r\n" +
"a=rtpmap:97 H264/90000\r\n" +
"a=fmtp:97 packetization-mode=1; profile-level-id=640028; sprop-parameter-sets=Z2QAKKy0A8ARPyo=,aO4Bniw=\r\n" +
"m=audio 0 RTP/AVP 0\r\n" +
"a=control:trackID=2\r\n" +
"a=recvonly\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"m=application 0 RTP/AVP 107\r\n" +
"a=control\r\n",
Session{
Medias: []*Media{
{
Type: "video",
Control: "trackID=1",
Formats: []format.Format{&format.H264{
PayloadTyp: 97,
PacketizationMode: 1,
SPS: []byte{0x67, 0x64, 0x00, 0x28, 0xac, 0xb4, 0x03, 0xc0, 0x11, 0x3f, 0x2a},
PPS: []byte{0x68, 0xee, 0x01, 0x9e, 0x2c},
}},
},
{
Type: "audio",
Direction: MediaDirectionRecvonly,
Control: "trackID=2",
Formats: []format.Format{&format.G711{
MULaw: true,
}},
},
{
Type: "application",
Formats: []format.Format{&format.Generic{
PayloadTyp: 107,
}},
},
},
},
},
{
"multiple formats for each media",
"v=0\r\n" +
"o=- 4158123474391860926 2 IN IP4 127.0.0.1\r\n" +
"s=-\r\n" +
"t=0 0\r\n" +
"a=group:BUNDLE audio video\r\n" +
"a=msid-semantic: WMS mediaSessionLocal\r\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 102 0 8 106 105 13 110 112 113 126\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
"a=ice-ufrag:0D6Y\r\n" +
"a=ice-pwd:V3YEqLGAJJhUDUa13C/pKbWe\r\n" +
"a=ice-options:trickle renomination\r\n" +
"a=fingerprint:sha-256" +
" 5E:B5:97:8B:B4:D8:AE:2B:89:F6:82:44:47:69:77:83:05:29:C5:C8:EE:67:50:C3:77:6B:A7:BA:10:E3:08:B8\r\n" +
"a=setup:actpass\r\n" +
"a=mid:audio\r\n" +
"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
"a=sendonly\r\n" +
"a=rtcp-mux\r\n" +
"a=rtpmap:111 opus/48000/2\r\n" +
"a=rtcp-fb:111 transport-cc\r\n" +
"a=fmtp:111 minptime=10;useinbandfec=1\r\n" +
"a=rtpmap:103 ISAC/16000\r\n" +
"a=rtpmap:104 ISAC/32000\r\n" +
"a=rtpmap:9 G722/8000\r\n" +
"a=rtpmap:102 ILBC/8000\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"a=rtpmap:8 PCMA/8000\r\n" +
"a=rtpmap:106 CN/32000\r\n" +
"a=rtpmap:105 CN/16000\r\n" +
"a=rtpmap:13 CN/8000\r\n" +
"a=rtpmap:110 telephone-event/48000\r\n" +
"a=rtpmap:112 telephone-event/32000\r\n" +
"a=rtpmap:113 telephone-event/16000\r\n" +
"a=rtpmap:126 telephone-event/8000\r\n" +
"a=ssrc:3754810229 cname:CvU1TYqkVsjj5XOt\r\n" +
"a=ssrc:3754810229 msid:mediaSessionLocal 101\r\n" +
"a=ssrc:3754810229 mslabel:mediaSessionLocal\r\n" +
"a=ssrc:3754810229 label:101\r\n" +
"m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 127 124 125\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
"a=ice-ufrag:0D6Y\r\n" +
"a=ice-pwd:V3YEqLGAJJhUDUa13C/pKbWe\r\n" +
"a=ice-options:trickle renomination\r\n" +
"a=fingerprint:sha-256" +
" 5E:B5:97:8B:B4:D8:AE:2B:89:F6:82:44:47:69:77:83:05:29:C5:C8:EE:67:50:C3:77:6B:A7:BA:10:E3:08:B8\r\n" +
"a=setup:actpass\r\n" +
"a=mid:video\r\n" +
"a=extmap:14 urn:ietf:params:rtp-hdrext:toffset\r\n" +
"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
"a=extmap:13 urn:3gpp:video-orientation\r\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
"a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\n" +
"a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\n" +
"a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\n" +
"a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\n" +
"a=sendonly\r\n" +
"a=rtcp-mux\r\n" +
"a=rtcp-rsize\r\n" +
"a=rtpmap:96 VP8/90000\r\n" +
"a=rtcp-fb:96 goog-remb\r\n" +
"a=rtcp-fb:96 transport-cc\r\n" +
"a=rtcp-fb:96 ccm fir\r\n" +
"a=rtcp-fb:96 nack\r\n" +
"a=rtcp-fb:96 nack pli\r\n" +
"a=rtpmap:97 rtx/90000\r\n" +
"a=fmtp:97 apt=96\r\n" +
"a=rtpmap:98 VP9/90000\r\n" +
"a=rtcp-fb:98 goog-remb\r\n" +
"a=rtcp-fb:98 transport-cc\r\n" +
"a=rtcp-fb:98 ccm fir\r\n" +
"a=rtcp-fb:98 nack\r\n" +
"a=rtcp-fb:98 nack pli\r\n" +
"a=rtpmap:99 rtx/90000\r\n" +
"a=fmtp:99 apt=98\r\n" +
"a=rtpmap:100 H264/90000\r\n" +
"a=rtcp-fb:100 goog-remb\r\n" +
"a=rtcp-fb:100 transport-cc\r\n" +
"a=rtcp-fb:100 ccm fir\r\n" +
"a=rtcp-fb:100 nack\r\n" +
"a=rtcp-fb:100 nack pli\r\n" +
"a=fmtp:100 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\n" +
"a=rtpmap:101 rtx/90000\r\n" +
"a=fmtp:101 apt=100\r\n" +
"a=rtpmap:127 red/90000\r\n" +
"a=rtpmap:124 rtx/90000\r\n" +
"a=fmtp:124 apt=127\r\n" +
"a=rtpmap:125 ulpfec/90000\r\n" +
"a=ssrc-group:FID 2712436124 1733091158\r\n" +
"a=ssrc:2712436124 cname:CvU1TYqkVsjj5XOt\r\n" +
"a=ssrc:2712436124 msid:mediaSessionLocal 100\r\n" +
"a=ssrc:2712436124 mslabel:mediaSessionLocal\r\n" +
"a=ssrc:2712436124 label:100\r\n" +
"a=ssrc:1733091158 cname:CvU1TYqkVsjj5XOt\r\n" +
"a=ssrc:1733091158 msid:mediaSessionLocal 100\r\n" +
"a=ssrc:1733091158 mslabel:mediaSessionLocal\r\n" +
"a=ssrc:1733091158 label:100\r\n",
"v=0\r\n" +
"o=- 0 0 IN IP4 127.0.0.1\r\n" +
"s=Session\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"t=0 0\r\n" +
"m=audio 0 RTP/AVP 111 103 104 9 102 0 8 106 105 13 110 112 113 126\r\n" +
"a=control\r\n" +
"a=sendonly\r\n" +
"a=rtpmap:111 opus/48000/2\r\n" +
"a=fmtp:111 sprop-stereo=0\r\n" +
"a=rtpmap:103 ISAC/16000\r\n" +
"a=rtpmap:104 ISAC/32000\r\n" +
"a=rtpmap:9 G722/8000\r\n" +
"a=rtpmap:102 ILBC/8000\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"a=rtpmap:8 PCMA/8000\r\n" +
"a=rtpmap:106 CN/32000\r\n" +
"a=rtpmap:105 CN/16000\r\n" +
"a=rtpmap:13 CN/8000\r\n" +
"a=rtpmap:110 telephone-event/48000\r\n" +
"a=rtpmap:112 telephone-event/32000\r\n" +
"a=rtpmap:113 telephone-event/16000\r\n" +
"a=rtpmap:126 telephone-event/8000\r\n" +
"m=video 0 RTP/AVP 96 97 98 99 100 101 127 124 125\r\n" +
"a=control\r\n" +
"a=sendonly\r\n" +
"a=rtpmap:96 VP8/90000\r\n" +
"a=rtpmap:97 rtx/90000\r\n" +
"a=fmtp:97 apt=96\r\n" +
"a=rtpmap:98 VP9/90000\r\n" +
"a=rtpmap:99 rtx/90000\r\n" +
"a=fmtp:99 apt=98\r\n" +
"a=rtpmap:100 H264/90000\r\n" +
"a=fmtp:100 packetization-mode=1\r\n" +
"a=rtpmap:101 rtx/90000\r\n" +
"a=fmtp:101 apt=100\r\n" +
"a=rtpmap:127 red/90000\r\n" +
"a=rtpmap:124 rtx/90000\r\n" +
"a=fmtp:124 apt=127\r\na=rtpmap:125 ulpfec/90000\r\n",
Session{
Medias: []*Media{
{
Type: "audio",
Direction: MediaDirectionSendonly,
Formats: []format.Format{
&format.Opus{
PayloadTyp: 111,
IsStereo: false,
},
&format.Generic{
PayloadTyp: 103,
RTPMa: "ISAC/16000",
ClockRat: 16000,
},
&format.Generic{
PayloadTyp: 104,
RTPMa: "ISAC/32000",
ClockRat: 32000,
},
&format.G722{},
&format.Generic{
PayloadTyp: 102,
RTPMa: "ILBC/8000",
ClockRat: 8000,
},
&format.G711{
MULaw: true,
},
&format.G711{
MULaw: false,
},
&format.Generic{
PayloadTyp: 106,
RTPMa: "CN/32000",
ClockRat: 32000,
},
&format.Generic{
PayloadTyp: 105,
RTPMa: "CN/16000",
ClockRat: 16000,
},
&format.Generic{
PayloadTyp: 13,
RTPMa: "CN/8000",
ClockRat: 8000,
},
&format.Generic{
PayloadTyp: 110,
RTPMa: "telephone-event/48000",
ClockRat: 48000,
},
&format.Generic{
PayloadTyp: 112,
RTPMa: "telephone-event/32000",
ClockRat: 32000,
},
&format.Generic{
PayloadTyp: 113,
RTPMa: "telephone-event/16000",
ClockRat: 16000,
},
&format.Generic{
PayloadTyp: 126,
RTPMa: "telephone-event/8000",
ClockRat: 8000,
},
},
},
{
Type: "video",
Direction: MediaDirectionSendonly,
Formats: []format.Format{
&format.VP8{
PayloadTyp: 96,
},
&format.Generic{
PayloadTyp: 97,
RTPMa: "rtx/90000",
FMT: map[string]string{
"apt": "96",
},
ClockRat: 90000,
},
&format.VP9{
PayloadTyp: 98,
},
&format.Generic{
PayloadTyp: 99,
RTPMa: "rtx/90000",
FMT: map[string]string{
"apt": "98",
},
ClockRat: 90000,
},
&format.H264{
PayloadTyp: 100,
PacketizationMode: 1,
},
&format.Generic{
PayloadTyp: 101,
RTPMa: "rtx/90000",
FMT: map[string]string{
"apt": "100",
},
ClockRat: 90000,
},
&format.Generic{
PayloadTyp: 127,
RTPMa: "red/90000",
ClockRat: 90000,
},
&format.Generic{
PayloadTyp: 124,
RTPMa: "rtx/90000",
FMT: map[string]string{
"apt": "127",
},
ClockRat: 90000,
},
&format.Generic{
PayloadTyp: 125,
RTPMa: "ulpfec/90000",
ClockRat: 90000,
},
},
},
},
},
},
{
"multiple formats for each media 2",
"v=0\r\n" +
"o=- 4158123474391860926 2 IN IP4 127.0.0.1\r\n" +
"s=-\r\n" +
"t=0 0\r\n" +
"m=video 42504 RTP/AVP 96 98\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=rtpmap:98 MetaData\r\n" +
"a=rtcp-mux\r\n" +
"a=fmtp:96 packetization-mode=1;profile-level-id=4d002a;" +
"sprop-parameter-sets=Z00AKp2oHgCJ+WbgICAgQA==,aO48gA==\r\n",
"v=0\r\n" +
"o=- 0 0 IN IP4 127.0.0.1\r\n" +
"s=Session\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"t=0 0\r\n" +
"m=video 0 RTP/AVP 96 98\r\n" +
"a=control\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=fmtp:96 packetization-mode=1; profile-level-id=4D002A; " +
"sprop-parameter-sets=Z00AKp2oHgCJ+WbgICAgQA==,aO48gA==\r\n" +
"a=rtpmap:98 MetaData\r\n",
Session{
Medias: []*Media{
{
Type: "video",
Formats: []format.Format{
&format.H264{
PayloadTyp: 96,
SPS: []byte{
0x67, 0x4d, 0x00, 0x2a, 0x9d, 0xa8, 0x1e, 0x00,
0x89, 0xf9, 0x66, 0xe0, 0x20, 0x20, 0x20, 0x40,
},
PPS: []byte{0x68, 0xee, 0x3c, 0x80},
PacketizationMode: 1,
},
&format.Generic{
PayloadTyp: 98,
RTPMa: "MetaData",
},
},
},
},
},
},
{
"onvif back channel",
"v=0\r\n" +
"o= 2890842807 IN IP4 192.168.0.1\r\n" +
"s=RTSP Session with audiobackchannel\r\n" +
"m=video 0 RTP/AVP 26\r\n" +
"a=control:rtsp://192.168.0.1/video\r\n" +
"a=recvonly\r\n" +
"m=audio 0 RTP/AVP 0\r\n" +
"a=control:rtsp://192.168.0.1/audio\r\n" +
"a=recvonly\r\n" +
"m=audio 0 RTP/AVP 0\r\n" +
"a=control:rtsp://192.168.0.1/audioback\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"a=sendonly\r\n",
"v=0\r\n" +
"o=- 0 0 IN IP4 127.0.0.1\r\n" +
"s=Session\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"t=0 0\r\n" +
"m=video 0 RTP/AVP 26\r\n" +
"a=control:rtsp://192.168.0.1/video\r\n" +
"a=recvonly\r\n" +
"a=rtpmap:26 JPEG/90000\r\n" +
"m=audio 0 RTP/AVP 0\r\n" +
"a=control:rtsp://192.168.0.1/audio\r\n" +
"a=recvonly\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"m=audio 0 RTP/AVP 0\r\n" +
"a=control:rtsp://192.168.0.1/audioback\r\n" +
"a=sendonly\r\n" +
"a=rtpmap:0 PCMU/8000\r\n",
Session{
Medias: []*Media{
{
Type: "video",
Direction: MediaDirectionRecvonly,
Control: "rtsp://192.168.0.1/video",
Formats: []format.Format{&format.MJPEG{}},
},
{
Type: "audio",
Direction: MediaDirectionRecvonly,
Control: "rtsp://192.168.0.1/audio",
Formats: []format.Format{&format.G711{MULaw: true}},
},
{
Type: "audio",
Direction: MediaDirectionSendonly,
Control: "rtsp://192.168.0.1/audioback",
Formats: []format.Format{&format.G711{MULaw: true}},
},
},
},
},
{
"tp-link",
"v=0\r\n" +
"o=- 4158123474391860926 2 IN IP4 127.0.0.1\r\n" +
"s=-\r\n" +
"t=0 0\r\n" +
"m=application/TP-LINK 0 RTP/AVP smart/1/90000\r\n" +
"a=rtpmap:95 TP-LINK/90000\r\n",
"v=0\r\n" +
"o=- 0 0 IN IP4 127.0.0.1\r\n" +
"s=Session\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"t=0 0\r\n" +
"m=application/TP-LINK 0 RTP/AVP 95\r\n" +
"a=control\r\n" +
"a=rtpmap:95 TP-LINK/90000\r\n",
Session{
Medias: []*Media{
{
Type: "application/TP-LINK",
Formats: []format.Format{&format.Generic{
PayloadTyp: 95,
RTPMa: "TP-LINK/90000",
ClockRat: 90000,
}},
},
},
},
},
{
"mercury",
"v=0\n" +
"o=- 14665860 31787219 1 IN IP4 192.168.0.60\n" +
"s=Session streamed by \"MERCURY RTSP Server\"\n" +
"t=0 0\n" +
"a=smart_encoder:virtualIFrame=1\n" +
"m=application/MERCURY 0 RTP/AVP smart/1/90000\n" +
"a=rtpmap:95 MERCURY/90000\n",
"v=0\r\n" +
"o=- 0 0 IN IP4 127.0.0.1\r\n" +
"s=Session\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"t=0 0\r\n" +
"m=application/MERCURY 0 RTP/AVP 95\r\n" +
"a=control\r\n" +
"a=rtpmap:95 MERCURY/90000\r\n",
Session{
Medias: []*Media{
{
Type: "application/MERCURY",
Formats: []format.Format{&format.Generic{
PayloadTyp: 95,
RTPMa: "MERCURY/90000",
ClockRat: 90000,
}},
},
},
},
},
{
"h264 with space at end",
"v=0\r\n" +
"o=- 4158123474391860926 2 IN IP4 127.0.0.1\r\n" +
"s=-\r\n" +
"t=0 0\r\n" +
"m=video 42504 RTP/AVP 96\r\n" +
"a=rtpmap:96 H264/90000 \r\n" +
"a=fmtp:96 packetization-mode=1\r\n",
"v=0\r\n" +
"o=- 0 0 IN IP4 127.0.0.1\r\n" +
"s=Session\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"t=0 0\r\n" +
"m=video 0 RTP/AVP 96\r\n" +
"a=control\r\n" +
"a=rtpmap:96 H264/90000\r\n" +
"a=fmtp:96 packetization-mode=1\r\n",
Session{
Medias: []*Media{
{
Type: "video",
Formats: []format.Format{
&format.H264{
PayloadTyp: 96,
PacketizationMode: 1,
},
},
},
},
},
},
}
func TestSessionUnmarshal(t *testing.T) {
for _, ca := range casesSession {
t.Run(ca.name, func(t *testing.T) {
var sdp sdp.SessionDescription
err := sdp.Unmarshal([]byte(ca.in))
require.NoError(t, err)
var desc Session
err = desc.Unmarshal(&sdp)
require.NoError(t, err)
require.Equal(t, ca.desc, desc)
})
}
}
func TestSessionUnmarshalErrors(t *testing.T) {
for _, ca := range []struct {
name string
sdp string
err string
}{
{
"invalid track",
"v=0\r\n" +
"o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5\r\n" +
"s=SDP Seminar\r\n" +
"m=video 0 RTP/AVP/TCP 96\r\n" +
"a=rtpmap:96 H265/90000\r\n" +
"a=fmtp:96 sprop-vps=QAEMAf//AWAAAAMAsAAAAwAAAwB4FwJA; " +
"sprop-sps=QgEBAWAAAAMAsAAAAwAAAwB4oAKggC8c1YgXuRZFL/y5/E/qbgQEBAE=; sprop-pps=RAHAcvBTJA==;\r\n" +
"a=control:streamid=0\r\n" +
"m=audio 0 RTP/AVP/TCP 97\r\n" +
"a=rtpmap:97 mpeg4-generic/44100/2\r\n" +
"a=fmtp:97 profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=zzz1210\r\n" +
"a=control:streamid=1\r\n",
"media 2 is invalid: invalid AAC config: zzz1210",
},
} {
t.Run(ca.name, func(t *testing.T) {
var sd sdp.SessionDescription
err := sd.Unmarshal([]byte(ca.sdp))
require.NoError(t, err)
var desc Session
err = desc.Unmarshal(&sd)
require.EqualError(t, err, ca.err)
})
}
}
func TestSessionMarshal(t *testing.T) {
for _, ca := range casesSession {
t.Run(ca.name, func(t *testing.T) {
byts, err := ca.desc.Marshal(false)
require.NoError(t, err)
require.Equal(t, ca.out, string(byts))
})
}
}
func TestSessionFindFormat(t *testing.T) {
tr := &format.Generic{
PayloadTyp: 97,
RTPMa: "rtx/90000",
FMT: map[string]string{
"apt": "96",
},
ClockRat: 90000,
}
md := &Media{
Type: MediaTypeVideo,
Formats: []format.Format{
&format.VP8{
PayloadTyp: 96,
},
tr,
&format.VP9{
PayloadTyp: 98,
},
},
}
desc := &Session{
Medias: []*Media{
{
Type: MediaTypeAudio,
Formats: []format.Format{
&format.Opus{
PayloadTyp: 111,
IsStereo: true,
},
},
},
md,
},
}
var forma *format.Generic
me := desc.FindFormat(&forma)
require.Equal(t, md, me)
require.Equal(t, tr, forma)
}