Merge commit '0da97ebd2149ec2a0412a273b17270f540431d81' as 'livekit-server'
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// Copyright 2024 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/atomic"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type agentClient struct {
|
||||
mu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
|
||||
registered atomic.Int32
|
||||
roomAvailability atomic.Int32
|
||||
roomJobs atomic.Int32
|
||||
publisherAvailability atomic.Int32
|
||||
publisherJobs atomic.Int32
|
||||
participantAvailability atomic.Int32
|
||||
participantJobs atomic.Int32
|
||||
|
||||
requestedJobs chan *livekit.Job
|
||||
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newAgentClient(token string, port uint32) (*agentClient, error) {
|
||||
host := fmt.Sprintf("ws://localhost:%d", port)
|
||||
u, err := url.Parse(host + "/agent")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requestHeader := make(http.Header)
|
||||
requestHeader.Set("Authorization", "Bearer "+token)
|
||||
|
||||
connectUrl := u.String()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(connectUrl, requestHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &agentClient{
|
||||
conn: conn,
|
||||
requestedJobs: make(chan *livekit.Job, 100),
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *agentClient) Run(jobType livekit.JobType, namespace string) (err error) {
|
||||
go c.read()
|
||||
|
||||
switch jobType {
|
||||
case livekit.JobType_JT_ROOM:
|
||||
err = c.write(&livekit.WorkerMessage{
|
||||
Message: &livekit.WorkerMessage_Register{
|
||||
Register: &livekit.RegisterWorkerRequest{
|
||||
Type: livekit.JobType_JT_ROOM,
|
||||
Version: "version",
|
||||
Namespace: &namespace,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
case livekit.JobType_JT_PUBLISHER:
|
||||
err = c.write(&livekit.WorkerMessage{
|
||||
Message: &livekit.WorkerMessage_Register{
|
||||
Register: &livekit.RegisterWorkerRequest{
|
||||
Type: livekit.JobType_JT_PUBLISHER,
|
||||
Version: "version",
|
||||
Namespace: &namespace,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
case livekit.JobType_JT_PARTICIPANT:
|
||||
err = c.write(&livekit.WorkerMessage{
|
||||
Message: &livekit.WorkerMessage_Register{
|
||||
Register: &livekit.RegisterWorkerRequest{
|
||||
Type: livekit.JobType_JT_PARTICIPANT,
|
||||
Version: "version",
|
||||
Namespace: &namespace,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *agentClient) read() {
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
_, b, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg := &livekit.ServerMessage{}
|
||||
if err = proto.Unmarshal(b, msg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch m := msg.Message.(type) {
|
||||
case *livekit.ServerMessage_Assignment:
|
||||
go c.handleAssignment(m.Assignment)
|
||||
case *livekit.ServerMessage_Availability:
|
||||
go c.handleAvailability(m.Availability)
|
||||
case *livekit.ServerMessage_Register:
|
||||
go c.handleRegister(m.Register)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *agentClient) handleAssignment(req *livekit.JobAssignment) {
|
||||
switch req.Job.Type {
|
||||
case livekit.JobType_JT_ROOM:
|
||||
c.roomJobs.Inc()
|
||||
case livekit.JobType_JT_PUBLISHER:
|
||||
c.publisherJobs.Inc()
|
||||
case livekit.JobType_JT_PARTICIPANT:
|
||||
c.participantJobs.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *agentClient) handleAvailability(req *livekit.AvailabilityRequest) {
|
||||
switch req.Job.Type {
|
||||
case livekit.JobType_JT_ROOM:
|
||||
c.roomAvailability.Inc()
|
||||
case livekit.JobType_JT_PUBLISHER:
|
||||
c.publisherAvailability.Inc()
|
||||
case livekit.JobType_JT_PARTICIPANT:
|
||||
c.participantAvailability.Inc()
|
||||
}
|
||||
|
||||
c.requestedJobs <- req.Job
|
||||
|
||||
c.write(&livekit.WorkerMessage{
|
||||
Message: &livekit.WorkerMessage_Availability{
|
||||
Availability: &livekit.AvailabilityResponse{
|
||||
JobId: req.Job.Id,
|
||||
Available: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *agentClient) handleRegister(req *livekit.RegisterWorkerResponse) {
|
||||
c.registered.Inc()
|
||||
}
|
||||
|
||||
func (c *agentClient) write(msg *livekit.WorkerMessage) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-c.done:
|
||||
return nil
|
||||
default:
|
||||
b, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.conn.WriteMessage(websocket.BinaryMessage, b)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *agentClient) close() {
|
||||
c.mu.Lock()
|
||||
close(c.done)
|
||||
_ = c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
_ = c.conn.Close()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/testutils"
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
var (
|
||||
RegisterTimeout = 2 * time.Second
|
||||
AssignJobTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
func TestAgents(t *testing.T) {
|
||||
_, finish := setupSingleNodeTest("TestAgents")
|
||||
defer finish()
|
||||
|
||||
ac1, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
ac2, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
ac3, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
ac4, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
ac5, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
ac6, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
defer ac1.close()
|
||||
defer ac2.close()
|
||||
defer ac3.close()
|
||||
defer ac4.close()
|
||||
defer ac5.close()
|
||||
defer ac6.close()
|
||||
ac1.Run(livekit.JobType_JT_ROOM, "default")
|
||||
ac2.Run(livekit.JobType_JT_ROOM, "default")
|
||||
ac3.Run(livekit.JobType_JT_PUBLISHER, "default")
|
||||
ac4.Run(livekit.JobType_JT_PUBLISHER, "default")
|
||||
ac5.Run(livekit.JobType_JT_PARTICIPANT, "default")
|
||||
ac6.Run(livekit.JobType_JT_PARTICIPANT, "default")
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ac1.registered.Load() != 1 || ac2.registered.Load() != 1 || ac3.registered.Load() != 1 || ac4.registered.Load() != 1 || ac5.registered.Load() != 1 || ac6.registered.Load() != 1 {
|
||||
return "worker not registered"
|
||||
}
|
||||
|
||||
return ""
|
||||
}, RegisterTimeout)
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("c2", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1, c2)
|
||||
|
||||
// publish 2 tracks
|
||||
t1, err := c1.AddStaticTrack("audio/opus", "audio", "micro")
|
||||
require.NoError(t, err)
|
||||
defer t1.Stop()
|
||||
t2, err := c1.AddStaticTrack("video/vp8", "video", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer t2.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ac1.roomJobs.Load()+ac2.roomJobs.Load() != 1 {
|
||||
return "room job not assigned"
|
||||
}
|
||||
|
||||
if ac3.publisherJobs.Load()+ac4.publisherJobs.Load() != 1 {
|
||||
return fmt.Sprintf("publisher jobs not assigned, ac3: %d, ac4: %d", ac3.publisherJobs.Load(), ac4.publisherJobs.Load())
|
||||
}
|
||||
|
||||
if ac5.participantJobs.Load()+ac6.participantJobs.Load() != 2 {
|
||||
return fmt.Sprintf("participant jobs not assigned, ac5: %d, ac6: %d", ac5.participantJobs.Load(), ac6.participantJobs.Load())
|
||||
}
|
||||
|
||||
return ""
|
||||
}, 6*time.Second)
|
||||
|
||||
// publish 2 tracks
|
||||
t3, err := c2.AddStaticTrack("audio/opus", "audio", "micro")
|
||||
require.NoError(t, err)
|
||||
defer t3.Stop()
|
||||
t4, err := c2.AddStaticTrack("video/vp8", "video", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer t4.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ac1.roomJobs.Load()+ac2.roomJobs.Load() != 1 {
|
||||
return "room job must be assigned 1 time"
|
||||
}
|
||||
|
||||
if ac3.publisherJobs.Load()+ac4.publisherJobs.Load() != 2 {
|
||||
return "2 publisher jobs must assigned"
|
||||
}
|
||||
|
||||
if ac5.participantJobs.Load()+ac6.participantJobs.Load() != 2 {
|
||||
return "2 participant jobs must assigned"
|
||||
}
|
||||
|
||||
return ""
|
||||
}, AssignJobTimeout)
|
||||
}
|
||||
|
||||
func TestAgentNamespaces(t *testing.T) {
|
||||
_, finish := setupSingleNodeTest("TestAgentNamespaces")
|
||||
defer finish()
|
||||
|
||||
ac1, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
ac2, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
defer ac1.close()
|
||||
defer ac2.close()
|
||||
ac1.Run(livekit.JobType_JT_ROOM, "namespace1")
|
||||
ac2.Run(livekit.JobType_JT_ROOM, "namespace2")
|
||||
|
||||
_, err = roomClient.CreateRoom(contextWithToken(createRoomToken()), &livekit.CreateRoomRequest{
|
||||
Name: testRoom,
|
||||
Agents: []*livekit.RoomAgentDispatch{
|
||||
{},
|
||||
{
|
||||
AgentName: "ag",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ac1.registered.Load() != 1 || ac2.registered.Load() != 1 {
|
||||
return "worker not registered"
|
||||
}
|
||||
return ""
|
||||
}, RegisterTimeout)
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ac1.roomJobs.Load() != 1 || ac2.roomJobs.Load() != 1 {
|
||||
return "room job not assigned"
|
||||
}
|
||||
|
||||
job1 := <-ac1.requestedJobs
|
||||
job2 := <-ac2.requestedJobs
|
||||
|
||||
if job1.Namespace != "namespace1" {
|
||||
return "namespace is not 'namespace'"
|
||||
}
|
||||
|
||||
if job2.Namespace != "namespace2" {
|
||||
return "namespace is not 'namespace2'"
|
||||
}
|
||||
|
||||
if job1.Id == job2.Id {
|
||||
return "job ids are the same"
|
||||
}
|
||||
|
||||
return ""
|
||||
}, AssignJobTimeout)
|
||||
|
||||
}
|
||||
|
||||
func TestAgentMultiNode(t *testing.T) {
|
||||
_, _, finish := setupMultiNodeTest("TestAgentMultiNode")
|
||||
defer finish()
|
||||
|
||||
ac1, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
ac2, err := newAgentClient(agentToken(), defaultServerPort)
|
||||
require.NoError(t, err)
|
||||
defer ac1.close()
|
||||
defer ac2.close()
|
||||
ac1.Run(livekit.JobType_JT_ROOM, "default")
|
||||
ac2.Run(livekit.JobType_JT_PUBLISHER, "default")
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ac1.registered.Load() != 1 || ac2.registered.Load() != 1 {
|
||||
return "worker not registered"
|
||||
}
|
||||
return ""
|
||||
}, RegisterTimeout)
|
||||
|
||||
c1 := createRTCClient("c1", secondServerPort, nil) // Create a room on the second node
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
t1, err := c1.AddStaticTrack("audio/opus", "audio", "micro")
|
||||
require.NoError(t, err)
|
||||
defer t1.Stop()
|
||||
|
||||
time.Sleep(time.Second * 10)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ac1.roomJobs.Load() != 1 {
|
||||
return "room job not assigned"
|
||||
}
|
||||
|
||||
if ac2.publisherJobs.Load() != 1 {
|
||||
return "participant job not assigned"
|
||||
}
|
||||
|
||||
return ""
|
||||
}, AssignJobTimeout)
|
||||
}
|
||||
|
||||
func agentToken() string {
|
||||
at := auth.NewAccessToken(testApiKey, testApiSecret).
|
||||
AddGrant(&auth.VideoGrant{Agent: true})
|
||||
t, err := at.ToJWT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/thoas/go-funk"
|
||||
"go.uber.org/atomic"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/mediatransportutil/pkg/rtcconfig"
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/buffer"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/mime"
|
||||
)
|
||||
|
||||
type SignalRequestHandler func(msg *livekit.SignalRequest) error
|
||||
type SignalRequestInterceptor func(msg *livekit.SignalRequest, next SignalRequestHandler) error
|
||||
type SignalResponseHandler func(msg *livekit.SignalResponse) error
|
||||
type SignalResponseInterceptor func(msg *livekit.SignalResponse, next SignalResponseHandler) error
|
||||
|
||||
type RTCClient struct {
|
||||
id livekit.ParticipantID
|
||||
conn *websocket.Conn
|
||||
publisher *rtc.PCTransport
|
||||
subscriber *rtc.PCTransport
|
||||
// sid => track
|
||||
localTracks map[string]webrtc.TrackLocal
|
||||
trackSenders map[string]*webrtc.RTPSender
|
||||
lock sync.Mutex
|
||||
wsLock sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
me *webrtc.MediaEngine // optional, populated only when receiving tracks
|
||||
subscribedTracks map[livekit.ParticipantID][]*webrtc.TrackRemote
|
||||
localParticipant *livekit.ParticipantInfo
|
||||
remoteParticipants map[livekit.ParticipantID]*livekit.ParticipantInfo
|
||||
|
||||
signalRequestInterceptor SignalRequestInterceptor
|
||||
signalResponseInterceptor SignalResponseInterceptor
|
||||
|
||||
icQueue [2]atomic.Pointer[webrtc.ICECandidate]
|
||||
|
||||
subscriberAsPrimary atomic.Bool
|
||||
publisherFullyEstablished atomic.Bool
|
||||
subscriberFullyEstablished atomic.Bool
|
||||
pongReceivedAt atomic.Int64
|
||||
lastAnswer atomic.Pointer[webrtc.SessionDescription]
|
||||
|
||||
// tracks waiting to be acked, cid => trackInfo
|
||||
pendingPublishedTracks map[string]*livekit.TrackInfo
|
||||
|
||||
pendingTrackWriters []*TrackWriter
|
||||
OnConnected func()
|
||||
OnDataReceived func(data []byte, sid string)
|
||||
refreshToken string
|
||||
|
||||
// map of livekit.ParticipantID and last packet
|
||||
lastPackets map[livekit.ParticipantID]*rtp.Packet
|
||||
bytesReceived map[livekit.ParticipantID]uint64
|
||||
|
||||
subscriptionResponse atomic.Pointer[livekit.SubscriptionResponse]
|
||||
}
|
||||
|
||||
var (
|
||||
// minimal settings only with stun server
|
||||
rtcConf = webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
extMimeMapping = map[string]string{
|
||||
".ivf": mime.MimeTypeVP8.String(),
|
||||
".h264": mime.MimeTypeH264.String(),
|
||||
".ogg": mime.MimeTypeOpus.String(),
|
||||
}
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
AutoSubscribe bool
|
||||
Publish string
|
||||
ClientInfo *livekit.ClientInfo
|
||||
DisabledCodecs []webrtc.RTPCodecCapability
|
||||
TokenCustomizer func(token *auth.AccessToken, grants *auth.VideoGrant)
|
||||
SignalRequestInterceptor SignalRequestInterceptor
|
||||
SignalResponseInterceptor SignalResponseInterceptor
|
||||
}
|
||||
|
||||
func NewWebSocketConn(host, token string, opts *Options) (*websocket.Conn, error) {
|
||||
u, err := url.Parse(host + fmt.Sprintf("/rtc?protocol=%d", types.CurrentProtocol))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requestHeader := make(http.Header)
|
||||
SetAuthorizationToken(requestHeader, token)
|
||||
|
||||
connectUrl := u.String()
|
||||
sdk := "go"
|
||||
if opts != nil {
|
||||
connectUrl = fmt.Sprintf("%s&auto_subscribe=%t", connectUrl, opts.AutoSubscribe)
|
||||
if opts.Publish != "" {
|
||||
connectUrl += encodeQueryParam("publish", opts.Publish)
|
||||
}
|
||||
if opts.ClientInfo != nil {
|
||||
if opts.ClientInfo.DeviceModel != "" {
|
||||
connectUrl += encodeQueryParam("device_model", opts.ClientInfo.DeviceModel)
|
||||
}
|
||||
if opts.ClientInfo.Os != "" {
|
||||
connectUrl += encodeQueryParam("os", opts.ClientInfo.Os)
|
||||
}
|
||||
if opts.ClientInfo.Sdk != livekit.ClientInfo_UNKNOWN {
|
||||
sdk = opts.ClientInfo.Sdk.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
connectUrl += encodeQueryParam("sdk", sdk)
|
||||
conn, _, err := websocket.DefaultDialer.Dial(connectUrl, requestHeader)
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func SetAuthorizationToken(header http.Header, token string) {
|
||||
header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
func NewRTCClient(conn *websocket.Conn, opts *Options) (*RTCClient, error) {
|
||||
var err error
|
||||
|
||||
c := &RTCClient{
|
||||
conn: conn,
|
||||
localTracks: make(map[string]webrtc.TrackLocal),
|
||||
trackSenders: make(map[string]*webrtc.RTPSender),
|
||||
pendingPublishedTracks: make(map[string]*livekit.TrackInfo),
|
||||
subscribedTracks: make(map[livekit.ParticipantID][]*webrtc.TrackRemote),
|
||||
remoteParticipants: make(map[livekit.ParticipantID]*livekit.ParticipantInfo),
|
||||
me: &webrtc.MediaEngine{},
|
||||
lastPackets: make(map[livekit.ParticipantID]*rtp.Packet),
|
||||
bytesReceived: make(map[livekit.ParticipantID]uint64),
|
||||
}
|
||||
c.ctx, c.cancel = context.WithCancel(context.Background())
|
||||
|
||||
conf := rtc.WebRTCConfig{
|
||||
WebRTCConfig: rtcconfig.WebRTCConfig{
|
||||
Configuration: rtcConf,
|
||||
},
|
||||
}
|
||||
conf.SettingEngine.SetLite(false)
|
||||
conf.SettingEngine.SetAnsweringDTLSRole(webrtc.DTLSRoleClient)
|
||||
ff := buffer.NewFactoryOfBufferFactory(500, 200)
|
||||
conf.SetBufferFactory(ff.CreateBufferFactory())
|
||||
var codecs []*livekit.Codec
|
||||
for _, codec := range []*livekit.Codec{
|
||||
{
|
||||
Mime: "audio/opus",
|
||||
},
|
||||
{
|
||||
Mime: "video/vp8",
|
||||
},
|
||||
{
|
||||
Mime: "video/h264",
|
||||
},
|
||||
} {
|
||||
var disabled bool
|
||||
if opts != nil {
|
||||
for _, dc := range opts.DisabledCodecs {
|
||||
if mime.IsMimeTypeStringEqual(dc.MimeType, codec.Mime) && (dc.SDPFmtpLine == "" || dc.SDPFmtpLine == codec.FmtpLine) {
|
||||
disabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !disabled {
|
||||
codecs = append(codecs, codec)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// The signal targets are from point of view of server.
|
||||
// From client side, they are flipped,
|
||||
// i. e. the publisher transport on client side has SUBSCRIBER signal target (i. e. publisher is offerer).
|
||||
// Same applies for subscriber transport also
|
||||
//
|
||||
publisherHandler := &transportfakes.FakeHandler{}
|
||||
c.publisher, err = rtc.NewPCTransport(rtc.TransportParams{
|
||||
Config: &conf,
|
||||
DirectionConfig: conf.Subscriber,
|
||||
EnabledCodecs: codecs,
|
||||
IsOfferer: true,
|
||||
IsSendSide: true,
|
||||
Handler: publisherHandler,
|
||||
DatachannelSlowThreshold: 1024 * 1024 * 1024,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subscriberHandler := &transportfakes.FakeHandler{}
|
||||
c.subscriber, err = rtc.NewPCTransport(rtc.TransportParams{
|
||||
Config: &conf,
|
||||
DirectionConfig: conf.Publisher,
|
||||
EnabledCodecs: codecs,
|
||||
Handler: subscriberHandler,
|
||||
DatachannelMaxReceiverBufferSize: 1500,
|
||||
FireOnTrackBySdp: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publisherHandler.OnICECandidateCalls(func(ic *webrtc.ICECandidate, t livekit.SignalTarget) error {
|
||||
return c.SendIceCandidate(ic, livekit.SignalTarget_PUBLISHER)
|
||||
})
|
||||
publisherHandler.OnOfferCalls(c.onOffer)
|
||||
publisherHandler.OnFullyEstablishedCalls(func() {
|
||||
logger.Debugw("publisher fully established", "participant", c.localParticipant.Identity, "pID", c.localParticipant.Sid)
|
||||
c.publisherFullyEstablished.Store(true)
|
||||
})
|
||||
|
||||
ordered := true
|
||||
if err := c.publisher.CreateDataChannel(rtc.ReliableDataChannel, &webrtc.DataChannelInit{
|
||||
Ordered: &ordered,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxRetransmits := uint16(0)
|
||||
if err := c.publisher.CreateDataChannel(rtc.LossyDataChannel, &webrtc.DataChannelInit{
|
||||
Ordered: &ordered,
|
||||
MaxRetransmits: &maxRetransmits,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subscriberHandler.OnICECandidateCalls(func(ic *webrtc.ICECandidate, t livekit.SignalTarget) error {
|
||||
if ic == nil {
|
||||
return nil
|
||||
}
|
||||
return c.SendIceCandidate(ic, livekit.SignalTarget_SUBSCRIBER)
|
||||
})
|
||||
subscriberHandler.OnTrackCalls(func(track *webrtc.TrackRemote, rtpReceiver *webrtc.RTPReceiver) {
|
||||
go c.processTrack(track)
|
||||
})
|
||||
subscriberHandler.OnDataPacketCalls(c.handleDataMessage)
|
||||
subscriberHandler.OnInitialConnectedCalls(func() {
|
||||
logger.Debugw("subscriber initial connected", "participant", c.localParticipant.Identity)
|
||||
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
for _, tw := range c.pendingTrackWriters {
|
||||
if err := tw.Start(); err != nil {
|
||||
logger.Errorw("track writer error", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.pendingTrackWriters = nil
|
||||
|
||||
if c.OnConnected != nil {
|
||||
go c.OnConnected()
|
||||
}
|
||||
})
|
||||
subscriberHandler.OnFullyEstablishedCalls(func() {
|
||||
logger.Debugw("subscriber fully established", "participant", c.localParticipant.Identity, "pID", c.localParticipant.Sid)
|
||||
c.subscriberFullyEstablished.Store(true)
|
||||
})
|
||||
subscriberHandler.OnAnswerCalls(func(answer webrtc.SessionDescription) error {
|
||||
// send remote an answer
|
||||
logger.Infow("sending subscriber answer",
|
||||
"participant", c.localParticipant.Identity,
|
||||
// "sdp", answer,
|
||||
)
|
||||
return c.SendRequest(&livekit.SignalRequest{
|
||||
Message: &livekit.SignalRequest_Answer{
|
||||
Answer: rtc.ToProtoSessionDescription(answer),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if opts != nil {
|
||||
c.signalRequestInterceptor = opts.SignalRequestInterceptor
|
||||
c.signalResponseInterceptor = opts.SignalResponseInterceptor
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *RTCClient) ID() livekit.ParticipantID {
|
||||
return c.id
|
||||
}
|
||||
|
||||
// create an offer for the server
|
||||
func (c *RTCClient) Run() error {
|
||||
c.conn.SetCloseHandler(func(code int, text string) error {
|
||||
// when closed, stop connection
|
||||
logger.Infow("connection closed", "code", code, "text", text)
|
||||
c.Stop()
|
||||
return nil
|
||||
})
|
||||
|
||||
// run the session
|
||||
for {
|
||||
res, err := c.ReadResponse()
|
||||
if errors.Is(io.EOF, err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
logger.Errorw("error while reading", err)
|
||||
return err
|
||||
}
|
||||
if c.signalResponseInterceptor != nil {
|
||||
err = c.signalResponseInterceptor(res, c.handleSignalResponse)
|
||||
} else {
|
||||
err = c.handleSignalResponse(res)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RTCClient) handleSignalResponse(res *livekit.SignalResponse) error {
|
||||
switch msg := res.Message.(type) {
|
||||
case *livekit.SignalResponse_Join:
|
||||
c.localParticipant = msg.Join.Participant
|
||||
c.id = livekit.ParticipantID(msg.Join.Participant.Sid)
|
||||
c.lock.Lock()
|
||||
for _, p := range msg.Join.OtherParticipants {
|
||||
c.remoteParticipants[livekit.ParticipantID(p.Sid)] = p
|
||||
}
|
||||
c.lock.Unlock()
|
||||
// if publish only, negotiate
|
||||
if !msg.Join.SubscriberPrimary {
|
||||
c.subscriberAsPrimary.Store(false)
|
||||
c.publisher.Negotiate(false)
|
||||
} else {
|
||||
c.subscriberAsPrimary.Store(true)
|
||||
}
|
||||
|
||||
logger.Infow("join accepted, awaiting offer", "participant", msg.Join.Participant.Identity)
|
||||
case *livekit.SignalResponse_Answer:
|
||||
// logger.Debugw("received server answer",
|
||||
// "participant", c.localParticipant.Identity,
|
||||
// "answer", msg.Answer.Sdp)
|
||||
c.handleAnswer(rtc.FromProtoSessionDescription(msg.Answer))
|
||||
case *livekit.SignalResponse_Offer:
|
||||
logger.Infow("received server offer",
|
||||
"participant", c.localParticipant.Identity,
|
||||
)
|
||||
desc := rtc.FromProtoSessionDescription(msg.Offer)
|
||||
c.handleOffer(desc)
|
||||
case *livekit.SignalResponse_Trickle:
|
||||
candidateInit, err := rtc.FromProtoTrickle(msg.Trickle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Trickle.Target == livekit.SignalTarget_PUBLISHER {
|
||||
c.publisher.AddICECandidate(candidateInit)
|
||||
} else {
|
||||
c.subscriber.AddICECandidate(candidateInit)
|
||||
}
|
||||
case *livekit.SignalResponse_Update:
|
||||
c.lock.Lock()
|
||||
for _, p := range msg.Update.Participants {
|
||||
if livekit.ParticipantID(p.Sid) != c.id {
|
||||
if p.State != livekit.ParticipantInfo_DISCONNECTED {
|
||||
c.remoteParticipants[livekit.ParticipantID(p.Sid)] = p
|
||||
} else {
|
||||
delete(c.remoteParticipants, livekit.ParticipantID(p.Sid))
|
||||
}
|
||||
}
|
||||
}
|
||||
c.lock.Unlock()
|
||||
|
||||
case *livekit.SignalResponse_TrackPublished:
|
||||
logger.Debugw("track published", "trackID", msg.TrackPublished.Track.Name, "participant", c.localParticipant.Sid,
|
||||
"cid", msg.TrackPublished.Cid, "trackSid", msg.TrackPublished.Track.Sid)
|
||||
c.lock.Lock()
|
||||
c.pendingPublishedTracks[msg.TrackPublished.Cid] = msg.TrackPublished.Track
|
||||
c.lock.Unlock()
|
||||
case *livekit.SignalResponse_RefreshToken:
|
||||
c.lock.Lock()
|
||||
c.refreshToken = msg.RefreshToken
|
||||
c.lock.Unlock()
|
||||
case *livekit.SignalResponse_TrackUnpublished:
|
||||
sid := msg.TrackUnpublished.TrackSid
|
||||
c.lock.Lock()
|
||||
sender := c.trackSenders[sid]
|
||||
if sender != nil {
|
||||
if err := c.publisher.RemoveTrack(sender); err != nil {
|
||||
logger.Errorw("Could not unpublish track", err)
|
||||
}
|
||||
c.publisher.Negotiate(false)
|
||||
}
|
||||
delete(c.trackSenders, sid)
|
||||
delete(c.localTracks, sid)
|
||||
c.lock.Unlock()
|
||||
case *livekit.SignalResponse_Pong:
|
||||
c.pongReceivedAt.Store(msg.Pong)
|
||||
case *livekit.SignalResponse_SubscriptionResponse:
|
||||
c.subscriptionResponse.Store(msg.SubscriptionResponse)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RTCClient) WaitUntilConnected() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
id := string(c.ID())
|
||||
if c.localParticipant != nil {
|
||||
id = c.localParticipant.Identity
|
||||
}
|
||||
return fmt.Errorf("%s could not connect after timeout", id)
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
if c.subscriberAsPrimary.Load() {
|
||||
if c.subscriberFullyEstablished.Load() {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
if c.publisherFullyEstablished.Load() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RTCClient) ReadResponse() (*livekit.SignalResponse, error) {
|
||||
for {
|
||||
// handle special messages and pass on the rest
|
||||
messageType, payload, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.ctx.Err() != nil {
|
||||
return nil, c.ctx.Err()
|
||||
}
|
||||
|
||||
msg := &livekit.SignalResponse{}
|
||||
switch messageType {
|
||||
case websocket.PingMessage:
|
||||
_ = c.conn.WriteMessage(websocket.PongMessage, nil)
|
||||
continue
|
||||
case websocket.BinaryMessage:
|
||||
// protobuf encoded
|
||||
err := proto.Unmarshal(payload, msg)
|
||||
return msg, err
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected message received: %v", messageType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RTCClient) SubscribedTracks() map[livekit.ParticipantID][]*webrtc.TrackRemote {
|
||||
// create a copy of this
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
tracks := make(map[livekit.ParticipantID][]*webrtc.TrackRemote, len(c.subscribedTracks))
|
||||
for key, val := range c.subscribedTracks {
|
||||
tracks[key] = val
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
func (c *RTCClient) RemoteParticipants() []*livekit.ParticipantInfo {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
return funk.Values(c.remoteParticipants).([]*livekit.ParticipantInfo)
|
||||
}
|
||||
|
||||
func (c *RTCClient) GetRemoteParticipant(sid livekit.ParticipantID) *livekit.ParticipantInfo {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
return c.remoteParticipants[sid]
|
||||
}
|
||||
|
||||
func (c *RTCClient) Stop() {
|
||||
logger.Infow("stopping client", "ID", c.ID())
|
||||
_ = c.SendRequest(&livekit.SignalRequest{
|
||||
Message: &livekit.SignalRequest_Leave{
|
||||
Leave: &livekit.LeaveRequest{
|
||||
Reason: livekit.DisconnectReason_CLIENT_INITIATED,
|
||||
Action: livekit.LeaveRequest_DISCONNECT,
|
||||
},
|
||||
},
|
||||
})
|
||||
c.publisherFullyEstablished.Store(false)
|
||||
c.subscriberFullyEstablished.Store(false)
|
||||
_ = c.conn.Close()
|
||||
c.publisher.Close()
|
||||
c.subscriber.Close()
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
func (c *RTCClient) RefreshToken() string {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
return c.refreshToken
|
||||
}
|
||||
|
||||
func (c *RTCClient) PongReceivedAt() int64 {
|
||||
return c.pongReceivedAt.Load()
|
||||
}
|
||||
|
||||
func (c *RTCClient) GetSubscriptionResponseAndClear() *livekit.SubscriptionResponse {
|
||||
return c.subscriptionResponse.Swap(nil)
|
||||
}
|
||||
|
||||
func (c *RTCClient) SendPing() error {
|
||||
return c.SendRequest(&livekit.SignalRequest{
|
||||
Message: &livekit.SignalRequest_Ping{
|
||||
Ping: time.Now().UnixNano(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *RTCClient) SendRequest(msg *livekit.SignalRequest) error {
|
||||
if c.signalRequestInterceptor != nil {
|
||||
return c.signalRequestInterceptor(msg, c.sendRequest)
|
||||
} else {
|
||||
return c.sendRequest(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RTCClient) sendRequest(msg *livekit.SignalRequest) error {
|
||||
payload, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.wsLock.Lock()
|
||||
defer c.wsLock.Unlock()
|
||||
return c.conn.WriteMessage(websocket.BinaryMessage, payload)
|
||||
}
|
||||
|
||||
func (c *RTCClient) SendIceCandidate(ic *webrtc.ICECandidate, target livekit.SignalTarget) error {
|
||||
prevIC := c.icQueue[target].Swap(ic)
|
||||
if prevIC == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.SendRequest(&livekit.SignalRequest{
|
||||
Message: &livekit.SignalRequest_Trickle{
|
||||
Trickle: rtc.ToProtoTrickle(prevIC.ToJSON(), target, ic == nil),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *RTCClient) SetAttributes(attrs map[string]string) error {
|
||||
return c.SendRequest(&livekit.SignalRequest{
|
||||
Message: &livekit.SignalRequest_UpdateMetadata{
|
||||
UpdateMetadata: &livekit.UpdateParticipantMetadata{
|
||||
Attributes: attrs,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *RTCClient) hasPrimaryEverConnected() bool {
|
||||
if c.subscriberAsPrimary.Load() {
|
||||
return c.subscriber.HasEverConnected()
|
||||
} else {
|
||||
return c.publisher.HasEverConnected()
|
||||
}
|
||||
}
|
||||
|
||||
type AddTrackParams struct {
|
||||
NoWriter bool
|
||||
}
|
||||
|
||||
type AddTrackOption func(params *AddTrackParams)
|
||||
|
||||
func AddTrackNoWriter() AddTrackOption {
|
||||
return func(params *AddTrackParams) {
|
||||
params.NoWriter = true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RTCClient) AddTrack(track *webrtc.TrackLocalStaticSample, path string, opts ...AddTrackOption) (writer *TrackWriter, err error) {
|
||||
var params AddTrackParams
|
||||
for _, opt := range opts {
|
||||
opt(¶ms)
|
||||
}
|
||||
trackType := livekit.TrackType_AUDIO
|
||||
if track.Kind() == webrtc.RTPCodecTypeVideo {
|
||||
trackType = livekit.TrackType_VIDEO
|
||||
}
|
||||
|
||||
if err = c.SendAddTrack(track.ID(), track.StreamID(), trackType); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// wait till track published message is received
|
||||
timeout := time.After(5 * time.Second)
|
||||
var ti *livekit.TrackInfo
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
return nil, errors.New("could not publish track after timeout")
|
||||
default:
|
||||
c.lock.Lock()
|
||||
ti = c.pendingPublishedTracks[track.ID()]
|
||||
c.lock.Unlock()
|
||||
if ti != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if ti != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
sender, _, err := c.publisher.AddTrack(track, types.AddTrackParams{})
|
||||
if err != nil {
|
||||
logger.Errorw("add track failed", err, "trackID", ti.Sid, "participant", c.localParticipant.Identity, "pID", c.localParticipant.Sid)
|
||||
return
|
||||
}
|
||||
c.localTracks[ti.Sid] = track
|
||||
c.trackSenders[ti.Sid] = sender
|
||||
c.publisher.Negotiate(false)
|
||||
|
||||
if !params.NoWriter {
|
||||
writer = NewTrackWriter(c.ctx, track, path)
|
||||
|
||||
// write tracks only after connection established
|
||||
if c.hasPrimaryEverConnected() {
|
||||
err = writer.Start()
|
||||
} else {
|
||||
c.pendingTrackWriters = append(c.pendingTrackWriters, writer)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *RTCClient) AddStaticTrack(mime string, id string, label string, opts ...AddTrackOption) (writer *TrackWriter, err error) {
|
||||
return c.AddStaticTrackWithCodec(webrtc.RTPCodecCapability{MimeType: mime}, id, label, opts...)
|
||||
}
|
||||
|
||||
func (c *RTCClient) AddStaticTrackWithCodec(codec webrtc.RTPCodecCapability, id string, label string, opts ...AddTrackOption) (writer *TrackWriter, err error) {
|
||||
track, err := webrtc.NewTrackLocalStaticSample(codec, id, label)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return c.AddTrack(track, "", opts...)
|
||||
}
|
||||
|
||||
func (c *RTCClient) AddFileTrack(path string, id string, label string) (writer *TrackWriter, err error) {
|
||||
// determine file mime
|
||||
mime, ok := extMimeMapping[filepath.Ext(path)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s has an unsupported extension", filepath.Base(path))
|
||||
}
|
||||
|
||||
logger.Debugw("adding file track",
|
||||
"mime", mime,
|
||||
)
|
||||
|
||||
track, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: mime},
|
||||
id,
|
||||
label,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return c.AddTrack(track, path)
|
||||
}
|
||||
|
||||
// send AddTrack command to server to initiate server-side negotiation
|
||||
func (c *RTCClient) SendAddTrack(cid string, name string, trackType livekit.TrackType) error {
|
||||
return c.SendRequest(&livekit.SignalRequest{
|
||||
Message: &livekit.SignalRequest_AddTrack{
|
||||
AddTrack: &livekit.AddTrackRequest{
|
||||
Cid: cid,
|
||||
Name: name,
|
||||
Type: trackType,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *RTCClient) PublishData(data []byte, kind livekit.DataPacket_Kind) error {
|
||||
if err := c.ensurePublisherConnected(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dpData, err := proto.Marshal(&livekit.DataPacket{
|
||||
Value: &livekit.DataPacket_User{
|
||||
User: &livekit.UserPacket{Payload: data},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.publisher.SendDataPacket(kind, dpData)
|
||||
}
|
||||
|
||||
func (c *RTCClient) GetPublishedTrackIDs() []string {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
var trackIDs []string
|
||||
for key := range c.localTracks {
|
||||
trackIDs = append(trackIDs, key)
|
||||
}
|
||||
return trackIDs
|
||||
}
|
||||
|
||||
// LastAnswer return SDP of the last answer for the publisher connection
|
||||
func (c *RTCClient) LastAnswer() *webrtc.SessionDescription {
|
||||
return c.lastAnswer.Load()
|
||||
}
|
||||
|
||||
func (c *RTCClient) ensurePublisherConnected() error {
|
||||
if c.publisher.HasEverConnected() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// start negotiating
|
||||
c.publisher.Negotiate(false)
|
||||
|
||||
// wait until connected, increase wait time since it takes more than 10s sometimes on GH
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("could not connect publisher after timeout")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
if c.publisherFullyEstablished.Load() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RTCClient) handleDataMessage(kind livekit.DataPacket_Kind, data []byte) {
|
||||
dp := &livekit.DataPacket{}
|
||||
err := proto.Unmarshal(data, dp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dp.Kind = kind
|
||||
if val, ok := dp.Value.(*livekit.DataPacket_User); ok {
|
||||
if c.OnDataReceived != nil {
|
||||
c.OnDataReceived(val.User.Payload, val.User.ParticipantSid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handles a server initiated offer, handle on subscriber PC
|
||||
func (c *RTCClient) handleOffer(desc webrtc.SessionDescription) {
|
||||
c.subscriber.HandleRemoteDescription(desc)
|
||||
}
|
||||
|
||||
// the client handles answer on the publisher PC
|
||||
func (c *RTCClient) handleAnswer(desc webrtc.SessionDescription) {
|
||||
logger.Infow("handling server answer", "participant", c.localParticipant.Identity)
|
||||
|
||||
c.lastAnswer.Store(&desc)
|
||||
// remote answered the offer, establish connection
|
||||
c.publisher.HandleRemoteDescription(desc)
|
||||
}
|
||||
|
||||
func (c *RTCClient) onOffer(offer webrtc.SessionDescription) error {
|
||||
if c.localParticipant != nil {
|
||||
logger.Infow("starting negotiation", "participant", c.localParticipant.Identity)
|
||||
}
|
||||
return c.SendRequest(&livekit.SignalRequest{
|
||||
Message: &livekit.SignalRequest_Offer{
|
||||
Offer: rtc.ToProtoSessionDescription(offer),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *RTCClient) processTrack(track *webrtc.TrackRemote) {
|
||||
lastUpdate := time.Time{}
|
||||
pId, trackId := rtc.UnpackStreamID(track.StreamID())
|
||||
if trackId == "" {
|
||||
trackId = livekit.TrackID(track.ID())
|
||||
}
|
||||
c.lock.Lock()
|
||||
c.subscribedTracks[pId] = append(c.subscribedTracks[pId], track)
|
||||
c.lock.Unlock()
|
||||
|
||||
logger.Infow("client added track", "participant", c.localParticipant.Identity,
|
||||
"pID", pId,
|
||||
"trackID", trackId,
|
||||
"codec", track.Codec(),
|
||||
)
|
||||
|
||||
defer func() {
|
||||
c.lock.Lock()
|
||||
c.subscribedTracks[pId] = funk.Without(c.subscribedTracks[pId], track).([]*webrtc.TrackRemote)
|
||||
c.lock.Unlock()
|
||||
}()
|
||||
|
||||
numBytes := 0
|
||||
for {
|
||||
pkt, _, err := track.ReadRTP()
|
||||
if c.ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if rtc.IsEOF(err) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warnw("error reading RTP", err)
|
||||
continue
|
||||
}
|
||||
c.lock.Lock()
|
||||
c.lastPackets[pId] = pkt
|
||||
c.bytesReceived[pId] += uint64(pkt.MarshalSize())
|
||||
c.lock.Unlock()
|
||||
numBytes += pkt.MarshalSize()
|
||||
if time.Since(lastUpdate) > 30*time.Second {
|
||||
logger.Infow("consumed from participant",
|
||||
"trackID", trackId, "pID", pId,
|
||||
"size", numBytes)
|
||||
lastUpdate = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RTCClient) BytesReceived() uint64 {
|
||||
var total uint64
|
||||
c.lock.Lock()
|
||||
for _, size := range c.bytesReceived {
|
||||
total += size
|
||||
}
|
||||
c.lock.Unlock()
|
||||
return total
|
||||
}
|
||||
|
||||
func (c *RTCClient) SendNacks(count int) {
|
||||
var packets []rtcp.Packet
|
||||
c.lock.Lock()
|
||||
for _, pkt := range c.lastPackets {
|
||||
seqs := make([]uint16, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
seqs = append(seqs, pkt.SequenceNumber-uint16(i))
|
||||
}
|
||||
packets = append(packets, &rtcp.TransportLayerNack{
|
||||
MediaSSRC: pkt.SSRC,
|
||||
Nacks: rtcp.NackPairsFromSequenceNumbers(seqs),
|
||||
})
|
||||
}
|
||||
c.lock.Unlock()
|
||||
|
||||
_ = c.subscriber.WriteRTCP(packets)
|
||||
}
|
||||
|
||||
func encodeQueryParam(key, value string) string {
|
||||
return fmt.Sprintf("&%s=%s", url.QueryEscape(key), url.QueryEscape(value))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/sfu/datachannel"
|
||||
)
|
||||
|
||||
type DataChannelReader struct {
|
||||
bitrate *datachannel.BitrateCalculator
|
||||
target int
|
||||
}
|
||||
|
||||
func NewDataChannelReader(bitrate int) *DataChannelReader {
|
||||
return &DataChannelReader{
|
||||
target: bitrate,
|
||||
bitrate: datachannel.NewBitrateCalculator(datachannel.BitrateDuration*5, datachannel.BitrateWindow),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataChannelReader) Read(p []byte, sid string) {
|
||||
for {
|
||||
if bitrate, ok := d.bitrate.ForceBitrate(time.Now()); ok && bitrate > 0 && bitrate > d.target {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
d.bitrate.AddBytes(0, 0, time.Now())
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
d.bitrate.AddBytes(len(p), 0, time.Now())
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/pion/webrtc/v4/pkg/media/h264reader"
|
||||
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
|
||||
"github.com/pion/webrtc/v4/pkg/media/oggreader"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/sfu/mime"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
// Writes a file to an RTP track.
|
||||
// makes it easier to debug and create RTP streams
|
||||
type TrackWriter struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
track *webrtc.TrackLocalStaticSample
|
||||
filePath string
|
||||
mime mime.MimeType
|
||||
|
||||
ogg *oggreader.OggReader
|
||||
ivfheader *ivfreader.IVFFileHeader
|
||||
ivf *ivfreader.IVFReader
|
||||
h264 *h264reader.H264Reader
|
||||
}
|
||||
|
||||
func NewTrackWriter(ctx context.Context, track *webrtc.TrackLocalStaticSample, filePath string) *TrackWriter {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &TrackWriter{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
track: track,
|
||||
filePath: filePath,
|
||||
mime: mime.NormalizeMimeType(track.Codec().MimeType),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *TrackWriter) Start() error {
|
||||
if w.filePath == "" {
|
||||
go w.writeNull()
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(w.filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debugw(
|
||||
"starting track writer",
|
||||
"trackID", w.track.ID(),
|
||||
"mime", w.mime,
|
||||
)
|
||||
switch w.mime {
|
||||
case mime.MimeTypeOpus:
|
||||
w.ogg, _, err = oggreader.NewWith(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go w.writeOgg()
|
||||
case mime.MimeTypeVP8:
|
||||
w.ivf, w.ivfheader, err = ivfreader.NewWith(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go w.writeVP8()
|
||||
case mime.MimeTypeH264:
|
||||
w.h264, err = h264reader.NewReader(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go w.writeH264()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *TrackWriter) Stop() {
|
||||
w.cancel()
|
||||
}
|
||||
|
||||
func (w *TrackWriter) writeNull() {
|
||||
defer w.onWriteComplete()
|
||||
sample := media.Sample{Data: []byte{0x0, 0xff, 0xff, 0xff, 0xff}, Duration: 30 * time.Millisecond}
|
||||
h264Sample := media.Sample{Data: []byte{0x00, 0x00, 0x00, 0x01, 0x7, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x01, 0x8, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x01, 0x5, 0xff, 0xff, 0xff, 0xff}, Duration: 30 * time.Millisecond}
|
||||
for {
|
||||
select {
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
if w.mime == mime.MimeTypeH264 {
|
||||
w.track.WriteSample(h264Sample)
|
||||
} else {
|
||||
w.track.WriteSample(sample)
|
||||
}
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *TrackWriter) writeOgg() {
|
||||
// Keep track of last granule, the difference is the amount of samples in the buffer
|
||||
var lastGranule uint64
|
||||
for {
|
||||
if w.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
pageData, pageHeader, err := w.ogg.ParseNextPage()
|
||||
if err == io.EOF {
|
||||
logger.Debugw("all audio samples parsed and sent")
|
||||
w.onWriteComplete()
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Errorw("could not parse ogg page", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The amount of samples is the difference between the last and current timestamp
|
||||
sampleCount := float64(pageHeader.GranulePosition - lastGranule)
|
||||
lastGranule = pageHeader.GranulePosition
|
||||
sampleDuration := time.Duration((sampleCount/48000)*1000) * time.Millisecond
|
||||
|
||||
if err = w.track.WriteSample(media.Sample{Data: pageData, Duration: sampleDuration}); err != nil {
|
||||
logger.Errorw("could not write sample", err)
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(sampleDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *TrackWriter) writeVP8() {
|
||||
// Send our video file frame at a time. Pace our sending such that we send it at the same speed it should be played back as.
|
||||
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
|
||||
sleepTime := time.Millisecond * time.Duration((float32(w.ivfheader.TimebaseNumerator)/float32(w.ivfheader.TimebaseDenominator))*1000)
|
||||
for {
|
||||
if w.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
frame, _, err := w.ivf.ParseNextFrame()
|
||||
if err == io.EOF {
|
||||
logger.Debugw("all video frames parsed and sent")
|
||||
w.onWriteComplete()
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Errorw("could not parse VP8 frame", err)
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(sleepTime)
|
||||
if err = w.track.WriteSample(media.Sample{Data: frame, Duration: time.Second}); err != nil {
|
||||
logger.Errorw("could not write sample", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *TrackWriter) writeH264() {
|
||||
// TODO: this is harder
|
||||
}
|
||||
|
||||
func (w *TrackWriter) onWriteComplete() {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/twitchtv/twirp"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/service"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
|
||||
"github.com/livekit/livekit-server/pkg/testutils"
|
||||
testclient "github.com/livekit/livekit-server/test/client"
|
||||
"github.com/livekit/mediatransportutil/pkg/rtcconfig"
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
)
|
||||
|
||||
const (
|
||||
testApiKey = "apikey"
|
||||
testApiSecret = "apiSecretExtendTo32BytesAsThatIsMinimum"
|
||||
testRoom = "mytestroom"
|
||||
defaultServerPort = 7880
|
||||
secondServerPort = 8880
|
||||
nodeID1 = "node-1"
|
||||
nodeID2 = "node-2"
|
||||
|
||||
syncDelay = 100 * time.Millisecond
|
||||
// if there are deadlocks, it's helpful to set a short test timeout (i.e. go test -timeout=30s)
|
||||
// let connection timeout happen
|
||||
// connectTimeout = 5000 * time.Second
|
||||
)
|
||||
|
||||
var roomClient livekit.RoomService
|
||||
|
||||
func init() {
|
||||
config.InitLoggerFromConfig(&config.DefaultConfig.Logging)
|
||||
|
||||
prometheus.Init("test", livekit.NodeType_SERVER)
|
||||
}
|
||||
|
||||
func setupSingleNodeTest(name string) (*service.LivekitServer, func()) {
|
||||
logger.Infow("----------------STARTING TEST----------------", "test", name)
|
||||
s := createSingleNodeServer(nil)
|
||||
go func() {
|
||||
if err := s.Start(); err != nil {
|
||||
logger.Errorw("server returned error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
waitForServerToStart(s)
|
||||
|
||||
return s, func() {
|
||||
s.Stop(true)
|
||||
logger.Infow("----------------FINISHING TEST----------------", "test", name)
|
||||
}
|
||||
}
|
||||
|
||||
func setupMultiNodeTest(name string) (*service.LivekitServer, *service.LivekitServer, func()) {
|
||||
logger.Infow("----------------STARTING TEST----------------", "test", name)
|
||||
s1 := createMultiNodeServer(guid.New(nodeID1), defaultServerPort)
|
||||
s2 := createMultiNodeServer(guid.New(nodeID2), secondServerPort)
|
||||
go s1.Start()
|
||||
go s2.Start()
|
||||
|
||||
waitForServerToStart(s1)
|
||||
waitForServerToStart(s2)
|
||||
|
||||
return s1, s2, func() {
|
||||
s1.Stop(true)
|
||||
s2.Stop(true)
|
||||
redisClient().FlushAll(context.Background())
|
||||
logger.Infow("----------------FINISHING TEST----------------", "test", name)
|
||||
}
|
||||
}
|
||||
|
||||
func contextWithToken(token string) context.Context {
|
||||
header := make(http.Header)
|
||||
testclient.SetAuthorizationToken(header, token)
|
||||
tctx, err := twirp.WithHTTPRequestHeaders(context.Background(), header)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return tctx
|
||||
}
|
||||
|
||||
func waitForServerToStart(s *service.LivekitServer) {
|
||||
// wait till ready
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutils.ConnectTimeout)
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
panic("could not start server after timeout")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
if s.IsRunning() {
|
||||
// ensure we can connect to it
|
||||
res, err := http.Get(fmt.Sprintf("http://localhost:%d", s.HTTPPort()))
|
||||
if err == nil && res.StatusCode == http.StatusOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilConnected(t *testing.T, clients ...*testclient.RTCClient) {
|
||||
logger.Infow("waiting for clients to become connected")
|
||||
wg := sync.WaitGroup{}
|
||||
for i := range clients {
|
||||
c := clients[i]
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := c.WaitUntilConnected()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if t.Failed() {
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func createSingleNodeServer(configUpdater func(*config.Config)) *service.LivekitServer {
|
||||
var err error
|
||||
conf, err := config.NewConfig("", true, nil, nil)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not create config: %v", err))
|
||||
}
|
||||
conf.Keys = map[string]string{testApiKey: testApiSecret}
|
||||
if configUpdater != nil {
|
||||
configUpdater(conf)
|
||||
}
|
||||
|
||||
currentNode, err := routing.NewLocalNode(conf)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not create local node: %v", err))
|
||||
}
|
||||
currentNode.SetNodeID(livekit.NodeID(guid.New(nodeID1)))
|
||||
|
||||
s, err := service.InitializeServer(conf, currentNode)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not create server: %v", err))
|
||||
}
|
||||
|
||||
roomClient = livekit.NewRoomServiceJSONClient(fmt.Sprintf("http://localhost:%d", defaultServerPort), &http.Client{})
|
||||
return s
|
||||
}
|
||||
|
||||
func createMultiNodeServer(nodeID string, port uint32) *service.LivekitServer {
|
||||
var err error
|
||||
conf, err := config.NewConfig("", true, nil, nil)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not create config: %v", err))
|
||||
}
|
||||
conf.Port = port
|
||||
conf.RTC.UDPPort = rtcconfig.PortRange{Start: int(port) + 1}
|
||||
conf.RTC.TCPPort = port + 2
|
||||
conf.Redis.Address = "localhost:6379"
|
||||
conf.Keys = map[string]string{testApiKey: testApiSecret}
|
||||
|
||||
currentNode, err := routing.NewLocalNode(conf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
currentNode.SetNodeID(livekit.NodeID(nodeID))
|
||||
|
||||
// redis routing and store
|
||||
s, err := service.InitializeServer(conf, currentNode)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not create server: %v", err))
|
||||
}
|
||||
|
||||
roomClient = livekit.NewRoomServiceJSONClient(fmt.Sprintf("http://localhost:%d", port), &http.Client{})
|
||||
return s
|
||||
}
|
||||
|
||||
// creates a client and runs against server
|
||||
func createRTCClient(name string, port int, opts *testclient.Options) *testclient.RTCClient {
|
||||
var customizer func(token *auth.AccessToken, grants *auth.VideoGrant)
|
||||
if opts != nil {
|
||||
customizer = opts.TokenCustomizer
|
||||
}
|
||||
token := joinToken(testRoom, name, customizer)
|
||||
ws, err := testclient.NewWebSocketConn(fmt.Sprintf("ws://localhost:%d", port), token, opts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
c, err := testclient.NewRTCClient(ws, opts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
go c.Run()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// creates a client and runs against server
|
||||
func createRTCClientWithToken(token string, port int, opts *testclient.Options) *testclient.RTCClient {
|
||||
ws, err := testclient.NewWebSocketConn(fmt.Sprintf("ws://localhost:%d", port), token, opts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
c, err := testclient.NewRTCClient(ws, opts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
go c.Run()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func redisClient() *redis.Client {
|
||||
return redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
}
|
||||
|
||||
func joinToken(room, name string, customFn func(token *auth.AccessToken, grants *auth.VideoGrant)) string {
|
||||
at := auth.NewAccessToken(testApiKey, testApiSecret).
|
||||
SetIdentity(name).
|
||||
SetName(name).
|
||||
SetMetadata("metadata" + name)
|
||||
grant := &auth.VideoGrant{RoomJoin: true, Room: room}
|
||||
if customFn != nil {
|
||||
customFn(at, grant)
|
||||
}
|
||||
at.AddGrant(grant)
|
||||
t, err := at.ToJWT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func joinTokenWithGrant(name string, grant *auth.VideoGrant) string {
|
||||
at := auth.NewAccessToken(testApiKey, testApiSecret).
|
||||
AddGrant(grant).
|
||||
SetIdentity(name).
|
||||
SetName(name)
|
||||
t, err := at.ToJWT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func createRoomToken() string {
|
||||
at := auth.NewAccessToken(testApiKey, testApiSecret).
|
||||
AddGrant(&auth.VideoGrant{RoomCreate: true})
|
||||
t, err := at.ToJWT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func adminRoomToken(name string) string {
|
||||
at := auth.NewAccessToken(testApiKey, testApiSecret).
|
||||
AddGrant(&auth.VideoGrant{RoomAdmin: true, Room: name})
|
||||
t, err := at.ToJWT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func listRoomToken() string {
|
||||
at := auth.NewAccessToken(testApiKey, testApiSecret).
|
||||
AddGrant(&auth.VideoGrant{RoomList: true})
|
||||
t, err := at.ToJWT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func stopWriters(writers ...*testclient.TrackWriter) {
|
||||
for _, w := range writers {
|
||||
w.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func stopClients(clients ...*testclient.RTCClient) {
|
||||
for _, c := range clients {
|
||||
c.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/testutils"
|
||||
)
|
||||
|
||||
func TestMultiNodeRoomList(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeRoomList")
|
||||
defer finish()
|
||||
|
||||
roomServiceListRoom(t)
|
||||
}
|
||||
|
||||
// update room metadata when it's empty
|
||||
func TestMultiNodeUpdateRoomMetadata(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("when room is empty", func(t *testing.T) {
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeUpdateRoomMetadata_empty")
|
||||
defer finish()
|
||||
|
||||
_, err := roomClient.CreateRoom(contextWithToken(createRoomToken()), &livekit.CreateRoomRequest{
|
||||
Name: "emptyRoom",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rm, err := roomClient.UpdateRoomMetadata(contextWithToken(adminRoomToken("emptyRoom")), &livekit.UpdateRoomMetadataRequest{
|
||||
Room: "emptyRoom",
|
||||
Metadata: "updated metadata",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "updated metadata", rm.Metadata)
|
||||
})
|
||||
|
||||
t.Run("when room has a participant", func(t *testing.T) {
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeUpdateRoomMetadata_with_participant")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1)
|
||||
defer c1.Stop()
|
||||
|
||||
_, err := roomClient.CreateRoom(contextWithToken(createRoomToken()), &livekit.CreateRoomRequest{
|
||||
Name: "emptyRoom",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rm, err := roomClient.UpdateRoomMetadata(contextWithToken(adminRoomToken("emptyRoom")), &livekit.UpdateRoomMetadataRequest{
|
||||
Room: "emptyRoom",
|
||||
Metadata: "updated metadata",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "updated metadata", rm.Metadata)
|
||||
})
|
||||
}
|
||||
|
||||
// remove a participant
|
||||
func TestMultiNodeRemoveParticipant(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeRemoveParticipant")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("mn_remove_participant", defaultServerPort, nil)
|
||||
defer c1.Stop()
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
ctx := contextWithToken(adminRoomToken(testRoom))
|
||||
_, err := roomClient.RemoveParticipant(ctx, &livekit.RoomParticipantIdentity{
|
||||
Room: testRoom,
|
||||
Identity: "mn_remove_participant",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// participant list doesn't show the participant
|
||||
listRes, err := roomClient.ListParticipants(ctx, &livekit.ListParticipantsRequest{
|
||||
Room: testRoom,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listRes.Participants, 0)
|
||||
}
|
||||
|
||||
// update participant metadata
|
||||
func TestMultiNodeUpdateParticipantMetadata(t *testing.T) {
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeUpdateParticipantMetadata")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("update_participant_metadata", defaultServerPort, nil)
|
||||
defer c1.Stop()
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
ctx := contextWithToken(adminRoomToken(testRoom))
|
||||
res, err := roomClient.UpdateParticipant(ctx, &livekit.UpdateParticipantRequest{
|
||||
Room: testRoom,
|
||||
Identity: "update_participant_metadata",
|
||||
Metadata: "the new metadata",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "the new metadata", res.Metadata)
|
||||
}
|
||||
|
||||
// admin mute published track
|
||||
func TestMultiNodeMutePublishedTrack(t *testing.T) {
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeMutePublishedTrack")
|
||||
defer finish()
|
||||
|
||||
identity := "mute_published_track"
|
||||
c1 := createRTCClient(identity, defaultServerPort, nil)
|
||||
defer c1.Stop()
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
writers := publishTracksForClients(t, c1)
|
||||
defer stopWriters(writers...)
|
||||
|
||||
trackIDs := c1.GetPublishedTrackIDs()
|
||||
require.NotEmpty(t, trackIDs)
|
||||
|
||||
ctx := contextWithToken(adminRoomToken(testRoom))
|
||||
// wait for it to be published before
|
||||
testutils.WithTimeout(t, func() string {
|
||||
res, err := roomClient.GetParticipant(ctx, &livekit.RoomParticipantIdentity{
|
||||
Room: testRoom,
|
||||
Identity: identity,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if len(res.Tracks) == 2 {
|
||||
return ""
|
||||
} else {
|
||||
return fmt.Sprintf("expected 2 tracks to be published, actual: %d", len(res.Tracks))
|
||||
}
|
||||
})
|
||||
|
||||
res, err := roomClient.MutePublishedTrack(ctx, &livekit.MuteRoomTrackRequest{
|
||||
Room: testRoom,
|
||||
Identity: identity,
|
||||
TrackSid: trackIDs[0],
|
||||
Muted: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, trackIDs[0], res.Track.Sid)
|
||||
require.True(t, res.Track.Muted)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc"
|
||||
"github.com/livekit/livekit-server/pkg/testutils"
|
||||
"github.com/livekit/livekit-server/test/client"
|
||||
)
|
||||
|
||||
func TestMultiNodeRouting(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeRouting")
|
||||
defer finish()
|
||||
|
||||
// creating room on node 1
|
||||
_, err := roomClient.CreateRoom(contextWithToken(createRoomToken()), &livekit.CreateRoomRequest{
|
||||
Name: testRoom,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// one node connecting to node 1, and another connecting to node 2
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("c2", secondServerPort, nil)
|
||||
waitUntilConnected(t, c1, c2)
|
||||
defer stopClients(c1, c2)
|
||||
|
||||
// c1 publishing, and c2 receiving
|
||||
t1, err := c1.AddStaticTrack("audio/opus", "audio", "webcam")
|
||||
require.NoError(t, err)
|
||||
if t1 != nil {
|
||||
defer t1.Stop()
|
||||
}
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c2.SubscribedTracks()) == 0 {
|
||||
return "c2 received no tracks"
|
||||
}
|
||||
if len(c2.SubscribedTracks()[c1.ID()]) != 1 {
|
||||
return "c2 didn't receive track published by c1"
|
||||
}
|
||||
tr1 := c2.SubscribedTracks()[c1.ID()][0]
|
||||
streamID, _ := rtc.UnpackStreamID(tr1.StreamID())
|
||||
require.Equal(t, c1.ID(), streamID)
|
||||
return ""
|
||||
})
|
||||
|
||||
remoteC1 := c2.GetRemoteParticipant(c1.ID())
|
||||
require.Equal(t, "c1", remoteC1.Name)
|
||||
require.Equal(t, "metadatac1", remoteC1.Metadata)
|
||||
}
|
||||
|
||||
func TestConnectWithoutCreation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, _, finish := setupMultiNodeTest("TestConnectWithoutCreation")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
c1.Stop()
|
||||
}
|
||||
|
||||
// testing multiple scenarios rooms
|
||||
func TestMultinodePublishingUponJoining(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
_, _, finish := setupMultiNodeTest("TestMultinodePublishingUponJoining")
|
||||
defer finish()
|
||||
|
||||
scenarioPublishingUponJoining(t)
|
||||
}
|
||||
|
||||
func TestMultinodeReceiveBeforePublish(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
_, _, finish := setupMultiNodeTest("TestMultinodeReceiveBeforePublish")
|
||||
defer finish()
|
||||
|
||||
scenarioReceiveBeforePublish(t)
|
||||
}
|
||||
|
||||
// reconnecting to the same room, after one of the servers has gone away
|
||||
func TestMultinodeReconnectAfterNodeShutdown(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, s2, finish := setupMultiNodeTest("TestMultinodeReconnectAfterNodeShutdown")
|
||||
defer finish()
|
||||
|
||||
// creating room on node 1
|
||||
_, err := roomClient.CreateRoom(contextWithToken(createRoomToken()), &livekit.CreateRoomRequest{
|
||||
Name: testRoom,
|
||||
NodeId: s2.Node().Id,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// one node connecting to node 1, and another connecting to node 2
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("c2", secondServerPort, nil)
|
||||
|
||||
waitUntilConnected(t, c1, c2)
|
||||
stopClients(c1, c2)
|
||||
|
||||
// stop s2, and connect to room again
|
||||
s2.Stop(true)
|
||||
|
||||
time.Sleep(syncDelay)
|
||||
|
||||
c3 := createRTCClient("c3", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c3)
|
||||
}
|
||||
|
||||
func TestMultinodeDataPublishing(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, _, finish := setupMultiNodeTest("TestMultinodeDataPublishing")
|
||||
defer finish()
|
||||
|
||||
scenarioDataPublish(t)
|
||||
}
|
||||
|
||||
func TestMultiNodeJoinAfterClose(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeJoinAfterClose")
|
||||
defer finish()
|
||||
|
||||
scenarioJoinClosedRoom(t)
|
||||
}
|
||||
|
||||
func TestMultiNodeCloseNonRTCRoom(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, _, finish := setupMultiNodeTest("closeNonRTCRoom")
|
||||
defer finish()
|
||||
|
||||
closeNonRTCRoom(t)
|
||||
}
|
||||
|
||||
// ensure that token accurately reflects out of band updates
|
||||
func TestMultiNodeRefreshToken(t *testing.T) {
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeJoinAfterClose")
|
||||
defer finish()
|
||||
|
||||
// a participant joining with full permissions
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
// update permissions and metadata
|
||||
ctx := contextWithToken(adminRoomToken(testRoom))
|
||||
_, err := roomClient.UpdateParticipant(ctx, &livekit.UpdateParticipantRequest{
|
||||
Room: testRoom,
|
||||
Identity: "c1",
|
||||
Permission: &livekit.ParticipantPermission{
|
||||
CanPublish: false,
|
||||
CanSubscribe: true,
|
||||
},
|
||||
Metadata: "metadata",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if c1.RefreshToken() == "" {
|
||||
return "did not receive refresh token"
|
||||
}
|
||||
// parse token to ensure it's correct
|
||||
verifier, err := auth.ParseAPIToken(c1.RefreshToken())
|
||||
require.NoError(t, err)
|
||||
|
||||
grants, err := verifier.Verify(testApiSecret)
|
||||
require.NoError(t, err)
|
||||
|
||||
if grants.Metadata != "metadata" {
|
||||
return "metadata did not match"
|
||||
}
|
||||
if *grants.Video.CanPublish {
|
||||
return "canPublish should be false"
|
||||
}
|
||||
if *grants.Video.CanPublishData {
|
||||
return "canPublishData should be false"
|
||||
}
|
||||
if !*grants.Video.CanSubscribe {
|
||||
return "canSubscribe should be true"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
// ensure that token accurately reflects out of band updates
|
||||
func TestMultiNodeUpdateAttributes(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeUpdateAttributes")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("au1", defaultServerPort, &client.Options{
|
||||
TokenCustomizer: func(token *auth.AccessToken, grants *auth.VideoGrant) {
|
||||
token.SetAttributes(map[string]string{
|
||||
"mykey": "au1",
|
||||
})
|
||||
},
|
||||
})
|
||||
c2 := createRTCClient("au2", secondServerPort, &client.Options{
|
||||
TokenCustomizer: func(token *auth.AccessToken, grants *auth.VideoGrant) {
|
||||
token.SetAttributes(map[string]string{
|
||||
"mykey": "au2",
|
||||
})
|
||||
grants.SetCanUpdateOwnMetadata(true)
|
||||
},
|
||||
})
|
||||
waitUntilConnected(t, c1, c2)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
rc2 := c1.GetRemoteParticipant(c2.ID())
|
||||
rc1 := c2.GetRemoteParticipant(c1.ID())
|
||||
if rc2 == nil || rc1 == nil {
|
||||
return "participants could not see each other"
|
||||
}
|
||||
if rc1.Attributes == nil || rc1.Attributes["mykey"] != "au1" {
|
||||
return "rc1's initial attributes are incorrect"
|
||||
}
|
||||
if rc2.Attributes == nil || rc2.Attributes["mykey"] != "au2" {
|
||||
return "rc2's initial attributes are incorrect"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// this one should not go through
|
||||
_ = c1.SetAttributes(map[string]string{"mykey": "shouldnotchange"})
|
||||
_ = c2.SetAttributes(map[string]string{"secondkey": "au2"})
|
||||
|
||||
// updates using room API should succeed
|
||||
_, err := roomClient.UpdateParticipant(contextWithToken(adminRoomToken(testRoom)), &livekit.UpdateParticipantRequest{
|
||||
Room: testRoom,
|
||||
Identity: "au1",
|
||||
Attributes: map[string]string{
|
||||
"secondkey": "au1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
rc1 := c2.GetRemoteParticipant(c1.ID())
|
||||
rc2 := c1.GetRemoteParticipant(c2.ID())
|
||||
if rc1.Attributes["secondkey"] != "au1" {
|
||||
return "au1's attribute update failed"
|
||||
}
|
||||
if rc2.Attributes["secondkey"] != "au2" {
|
||||
return "au2's attribute update failed"
|
||||
}
|
||||
if rc1.Attributes["mykey"] != "au1" {
|
||||
return "au1's mykey should not change"
|
||||
}
|
||||
if rc2.Attributes["mykey"] != "au2" {
|
||||
return "au2's mykey should not change"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
func TestMultiNodeRevokePublishPermission(t *testing.T) {
|
||||
_, _, finish := setupMultiNodeTest("TestMultiNodeRevokePublishPermission")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("c2", secondServerPort, nil)
|
||||
waitUntilConnected(t, c1, c2)
|
||||
|
||||
// c1 publishes a track for c2
|
||||
writers := publishTracksForClients(t, c1)
|
||||
defer stopWriters(writers...)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c2.SubscribedTracks()[c1.ID()]) != 2 {
|
||||
return "c2 did not receive c1's tracks"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// revoke permission
|
||||
ctx := contextWithToken(adminRoomToken(testRoom))
|
||||
_, err := roomClient.UpdateParticipant(ctx, &livekit.UpdateParticipantRequest{
|
||||
Room: testRoom,
|
||||
Identity: "c1",
|
||||
Permission: &livekit.ParticipantPermission{
|
||||
CanPublish: false,
|
||||
CanPublishData: true,
|
||||
CanSubscribe: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// ensure c1 no longer has track published, c2 no longer see track under C1
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c1.GetPublishedTrackIDs()) != 0 {
|
||||
return "c1 did not unpublish tracks"
|
||||
}
|
||||
remoteC1 := c2.GetRemoteParticipant(c1.ID())
|
||||
if remoteC1 == nil {
|
||||
return "c2 doesn't know about c1"
|
||||
}
|
||||
if len(remoteC1.Tracks) != 0 {
|
||||
return "c2 still has c1's tracks"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
func TestCloseDisconnectedParticipantOnSignalClose(t *testing.T) {
|
||||
_, _, finish := setupMultiNodeTest("TestCloseDisconnectedParticipantOnSignalClose")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", secondServerPort, nil)
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
c2 := createRTCClient("c2", defaultServerPort, &client.Options{
|
||||
SignalRequestInterceptor: func(msg *livekit.SignalRequest, next client.SignalRequestHandler) error {
|
||||
switch msg.Message.(type) {
|
||||
case *livekit.SignalRequest_Offer, *livekit.SignalRequest_Answer, *livekit.SignalRequest_Leave:
|
||||
return nil
|
||||
default:
|
||||
return next(msg)
|
||||
}
|
||||
},
|
||||
SignalResponseInterceptor: func(msg *livekit.SignalResponse, next client.SignalResponseHandler) error {
|
||||
switch msg.Message.(type) {
|
||||
case *livekit.SignalResponse_Offer, *livekit.SignalResponse_Answer:
|
||||
return nil
|
||||
default:
|
||||
return next(msg)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c1.RemoteParticipants()) != 1 {
|
||||
return "c1 did not see c2 join"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
c2.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c1.RemoteParticipants()) != 0 {
|
||||
return "c1 did not see c2 removed"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/testutils"
|
||||
testclient "github.com/livekit/livekit-server/test/client"
|
||||
)
|
||||
|
||||
// a scenario with lots of clients connecting, publishing, and leaving at random periods
|
||||
func scenarioPublishingUponJoining(t *testing.T) {
|
||||
c1 := createRTCClient("puj_1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("puj_2", secondServerPort, &testclient.Options{AutoSubscribe: true})
|
||||
c3 := createRTCClient("puj_3", defaultServerPort, &testclient.Options{AutoSubscribe: true})
|
||||
defer stopClients(c1, c2, c3)
|
||||
|
||||
waitUntilConnected(t, c1, c2, c3)
|
||||
|
||||
// c1 and c2 publishing, c3 just receiving
|
||||
writers := publishTracksForClients(t, c1, c2)
|
||||
defer stopWriters(writers...)
|
||||
|
||||
logger.Infow("waiting to receive tracks from c1 and c2")
|
||||
testutils.WithTimeout(t, func() string {
|
||||
tracks := c3.SubscribedTracks()
|
||||
if len(tracks[c1.ID()]) != 2 {
|
||||
return "did not receive tracks from c1"
|
||||
}
|
||||
if len(tracks[c2.ID()]) != 2 {
|
||||
return "did not receive tracks from c2"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// after a delay, c2 reconnects, then publishing
|
||||
time.Sleep(syncDelay)
|
||||
c2.Stop()
|
||||
|
||||
logger.Infow("waiting for c2 tracks to be gone")
|
||||
testutils.WithTimeout(t, func() string {
|
||||
tracks := c3.SubscribedTracks()
|
||||
|
||||
if len(tracks[c1.ID()]) != 2 {
|
||||
return fmt.Sprintf("c3 should be subscribed to 2 tracks from c1, actual: %d", len(tracks[c1.ID()]))
|
||||
}
|
||||
if len(tracks[c2.ID()]) != 0 {
|
||||
return fmt.Sprintf("c3 should be subscribed to 0 tracks from c2, actual: %d", len(tracks[c2.ID()]))
|
||||
}
|
||||
if len(c1.SubscribedTracks()[c2.ID()]) != 0 {
|
||||
return fmt.Sprintf("c3 should be subscribed to 0 tracks from c2, actual: %d", len(c1.SubscribedTracks()[c2.ID()]))
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
logger.Infow("c2 reconnecting")
|
||||
// connect to a diff port
|
||||
c2 = createRTCClient("puj_2", defaultServerPort, nil)
|
||||
defer c2.Stop()
|
||||
waitUntilConnected(t, c2)
|
||||
writers = publishTracksForClients(t, c2)
|
||||
defer stopWriters(writers...)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
tracks := c3.SubscribedTracks()
|
||||
// "new c2 tracks should be published again",
|
||||
if len(tracks[c2.ID()]) != 2 {
|
||||
return fmt.Sprintf("c3 should be subscribed to 2 tracks from c2, actual: %d", len(tracks[c2.ID()]))
|
||||
}
|
||||
if len(c1.SubscribedTracks()[c2.ID()]) != 2 {
|
||||
return fmt.Sprintf("c1 should be subscribed to 2 tracks from c2, actual: %d", len(c1.SubscribedTracks()[c2.ID()]))
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
func scenarioReceiveBeforePublish(t *testing.T) {
|
||||
c1 := createRTCClient("rbp_1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("rbp_2", defaultServerPort, nil)
|
||||
|
||||
waitUntilConnected(t, c1, c2)
|
||||
defer stopClients(c1, c2)
|
||||
|
||||
// c1 publishes
|
||||
writers := publishTracksForClients(t, c1)
|
||||
defer stopWriters(writers...)
|
||||
|
||||
// c2 should see some bytes flowing through
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if c2.BytesReceived() > 20 {
|
||||
return ""
|
||||
} else {
|
||||
return fmt.Sprintf("c2 only received %d bytes", c2.BytesReceived())
|
||||
}
|
||||
})
|
||||
|
||||
// now publish on C2
|
||||
writers = publishTracksForClients(t, c2)
|
||||
defer stopWriters(writers...)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c1.SubscribedTracks()[c2.ID()]) == 2 {
|
||||
return ""
|
||||
} else {
|
||||
return fmt.Sprintf("expected c1 to receive 2 tracks from c2, actual: %d", len(c1.SubscribedTracks()[c2.ID()]))
|
||||
}
|
||||
})
|
||||
|
||||
// now leave, and ensure that it's immediate
|
||||
c2.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c1.RemoteParticipants()) > 0 {
|
||||
return fmt.Sprintf("expected no remote participants, actual: %v", c1.RemoteParticipants())
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
func scenarioDataPublish(t *testing.T) {
|
||||
c1 := createRTCClient("dp1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("dp2", secondServerPort, nil)
|
||||
waitUntilConnected(t, c1, c2)
|
||||
defer stopClients(c1, c2)
|
||||
|
||||
payload := "test bytes"
|
||||
|
||||
received := atomic.NewBool(false)
|
||||
c2.OnDataReceived = func(data []byte, sid string) {
|
||||
if string(data) == payload && livekit.ParticipantID(sid) == c1.ID() {
|
||||
received.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, c1.PublishData([]byte(payload), livekit.DataPacket_RELIABLE))
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if received.Load() {
|
||||
return ""
|
||||
} else {
|
||||
return "c2 did not receive published data"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func scenarioJoinClosedRoom(t *testing.T) {
|
||||
c1 := createRTCClient("jcr1", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
// close room with room client
|
||||
_, err := roomClient.DeleteRoom(contextWithToken(createRoomToken()), &livekit.DeleteRoomRequest{
|
||||
Room: testRoom,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// now join again
|
||||
c2 := createRTCClient("jcr2", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c2)
|
||||
stopClients(c2)
|
||||
}
|
||||
|
||||
// close a room that has been created, but no participant has joined
|
||||
func closeNonRTCRoom(t *testing.T) {
|
||||
createCtx := contextWithToken(createRoomToken())
|
||||
_, err := roomClient.CreateRoom(createCtx, &livekit.CreateRoomRequest{
|
||||
Name: testRoom,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = roomClient.DeleteRoom(createCtx, &livekit.DeleteRoomRequest{
|
||||
Room: testRoom,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func publishTracksForClients(t *testing.T, clients ...*testclient.RTCClient) []*testclient.TrackWriter {
|
||||
logger.Infow("publishing tracks for clients")
|
||||
var writers []*testclient.TrackWriter
|
||||
for i := range clients {
|
||||
c := clients[i]
|
||||
tw, err := c.AddStaticTrack("audio/opus", "audio", "webcam")
|
||||
require.NoError(t, err)
|
||||
|
||||
writers = append(writers, tw)
|
||||
tw, err = c.AddStaticTrack("video/vp8", "video", "webcam")
|
||||
require.NoError(t, err)
|
||||
writers = append(writers, tw)
|
||||
}
|
||||
return writers
|
||||
}
|
||||
|
||||
// Room service tests
|
||||
|
||||
func roomServiceListRoom(t *testing.T) {
|
||||
createCtx := contextWithToken(createRoomToken())
|
||||
listCtx := contextWithToken(listRoomToken())
|
||||
// create rooms
|
||||
_, err := roomClient.CreateRoom(createCtx, &livekit.CreateRoomRequest{
|
||||
Name: testRoom,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = roomClient.CreateRoom(contextWithToken(createRoomToken()), &livekit.CreateRoomRequest{
|
||||
Name: "yourroom",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("list all rooms", func(t *testing.T) {
|
||||
res, err := roomClient.ListRooms(listCtx, &livekit.ListRoomsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Rooms, 2)
|
||||
})
|
||||
t.Run("list specific rooms", func(t *testing.T) {
|
||||
res, err := roomClient.ListRooms(listCtx, &livekit.ListRoomsRequest{
|
||||
Names: []string{"yourroom"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Rooms, 1)
|
||||
require.Equal(t, "yourroom", res.Rooms[0].Name)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thoas/go-funk"
|
||||
"github.com/twitchtv/twirp"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/rtc"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/datachannel"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/mime"
|
||||
"github.com/livekit/livekit-server/pkg/testutils"
|
||||
testclient "github.com/livekit/livekit-server/test/client"
|
||||
)
|
||||
|
||||
const (
|
||||
waitTick = 10 * time.Millisecond
|
||||
waitTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
func TestClientCouldConnect(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, finish := setupSingleNodeTest("TestClientCouldConnect")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("c2", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1, c2)
|
||||
|
||||
// ensure they both see each other
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c1.RemoteParticipants()) == 0 {
|
||||
return "c1 did not see c2"
|
||||
}
|
||||
if len(c2.RemoteParticipants()) == 0 {
|
||||
return "c2 did not see c1"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientConnectDuplicate(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, finish := setupSingleNodeTest("TestClientCouldConnect")
|
||||
defer finish()
|
||||
|
||||
grant := &auth.VideoGrant{RoomJoin: true, Room: testRoom}
|
||||
grant.SetCanPublish(true)
|
||||
grant.SetCanSubscribe(true)
|
||||
token := joinTokenWithGrant("c1", grant)
|
||||
|
||||
c1 := createRTCClientWithToken(token, defaultServerPort, nil)
|
||||
|
||||
// publish 2 tracks
|
||||
t1, err := c1.AddStaticTrack("audio/opus", "audio", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer t1.Stop()
|
||||
t2, err := c1.AddStaticTrack("video/vp8", "video", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer t2.Stop()
|
||||
|
||||
c2 := createRTCClient("c2", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1, c2)
|
||||
|
||||
opts := &testclient.Options{
|
||||
Publish: "duplicate_connection",
|
||||
}
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c2.SubscribedTracks()) == 0 {
|
||||
return "c2 didn't subscribe to anything"
|
||||
}
|
||||
// should have received three tracks
|
||||
if len(c2.SubscribedTracks()[c1.ID()]) != 2 {
|
||||
return "c2 didn't subscribe to both tracks from c1"
|
||||
}
|
||||
|
||||
// participant ID can be appended with '#..' . but should contain orig id as prefix
|
||||
tr1 := c2.SubscribedTracks()[c1.ID()][0]
|
||||
participantId1, _ := rtc.UnpackStreamID(tr1.StreamID())
|
||||
require.Equal(t, c1.ID(), participantId1)
|
||||
tr2 := c2.SubscribedTracks()[c1.ID()][1]
|
||||
participantId2, _ := rtc.UnpackStreamID(tr2.StreamID())
|
||||
require.Equal(t, c1.ID(), participantId2)
|
||||
return ""
|
||||
})
|
||||
|
||||
c1Dup := createRTCClientWithToken(token, defaultServerPort, opts)
|
||||
|
||||
waitUntilConnected(t, c1Dup)
|
||||
|
||||
t3, err := c1Dup.AddStaticTrack("video/vp8", "video", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer t3.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c2.SubscribedTracks()[c1Dup.ID()]) != 1 {
|
||||
return "c2 was not subscribed to track from duplicated c1"
|
||||
}
|
||||
|
||||
tr3 := c2.SubscribedTracks()[c1Dup.ID()][0]
|
||||
participantId3, _ := rtc.UnpackStreamID(tr3.StreamID())
|
||||
require.Contains(t, c1Dup.ID(), participantId3)
|
||||
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
func TestSinglePublisher(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
s, finish := setupSingleNodeTest("TestSinglePublisher")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("c2", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1, c2)
|
||||
|
||||
// publish a track and ensure clients receive it ok
|
||||
t1, err := c1.AddStaticTrack("audio/OPUS", "audio", "webcamaudio")
|
||||
require.NoError(t, err)
|
||||
defer t1.Stop()
|
||||
t2, err := c1.AddStaticTrack("video/vp8", "video", "webcamvideo")
|
||||
require.NoError(t, err)
|
||||
defer t2.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c2.SubscribedTracks()) == 0 {
|
||||
return "c2 was not subscribed to anything"
|
||||
}
|
||||
// should have received two tracks
|
||||
if len(c2.SubscribedTracks()[c1.ID()]) != 2 {
|
||||
return "c2 didn't subscribe to both tracks from c1"
|
||||
}
|
||||
|
||||
tr1 := c2.SubscribedTracks()[c1.ID()][0]
|
||||
participantId, _ := rtc.UnpackStreamID(tr1.StreamID())
|
||||
require.Equal(t, c1.ID(), participantId)
|
||||
return ""
|
||||
})
|
||||
// ensure mime type is received
|
||||
remoteC1 := c2.GetRemoteParticipant(c1.ID())
|
||||
audioTrack := funk.Find(remoteC1.Tracks, func(ti *livekit.TrackInfo) bool {
|
||||
return ti.Name == "webcamaudio"
|
||||
}).(*livekit.TrackInfo)
|
||||
require.Equal(t, "audio/opus", audioTrack.MimeType)
|
||||
|
||||
// a new client joins and should get the initial stream
|
||||
c3 := createRTCClient("c3", defaultServerPort, nil)
|
||||
|
||||
// ensure that new client that has joined also received tracks
|
||||
waitUntilConnected(t, c3)
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c3.SubscribedTracks()) == 0 {
|
||||
return "c3 didn't subscribe to anything"
|
||||
}
|
||||
// should have received two tracks
|
||||
if len(c3.SubscribedTracks()[c1.ID()]) != 2 {
|
||||
return "c3 didn't subscribe to tracks from c1"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// ensure that the track ids are generated by server
|
||||
tracks := c3.SubscribedTracks()[c1.ID()]
|
||||
for _, tr := range tracks {
|
||||
require.True(t, strings.HasPrefix(tr.ID(), "TR_"), "track should begin with TR")
|
||||
}
|
||||
|
||||
// when c3 disconnects, ensure subscriber is cleaned up correctly
|
||||
c3.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
room := s.RoomManager().GetRoom(context.Background(), testRoom)
|
||||
p := room.GetParticipant("c1")
|
||||
require.NotNil(t, p)
|
||||
|
||||
for _, t := range p.GetPublishedTracks() {
|
||||
if t.IsSubscriber(c3.ID()) {
|
||||
return "c3 was not a subscriber of c1's tracks"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
func Test_WhenAutoSubscriptionDisabled_ClientShouldNotReceiveAnyPublishedTracks(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, finish := setupSingleNodeTest("Test_WhenAutoSubscriptionDisabled_ClientShouldNotReceiveAnyPublishedTracks")
|
||||
defer finish()
|
||||
|
||||
opts := testclient.Options{AutoSubscribe: false}
|
||||
publisher := createRTCClient("publisher", defaultServerPort, &opts)
|
||||
client := createRTCClient("client", defaultServerPort, &opts)
|
||||
defer publisher.Stop()
|
||||
defer client.Stop()
|
||||
waitUntilConnected(t, publisher, client)
|
||||
|
||||
track, err := publisher.AddStaticTrack("audio/opus", "audio", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer track.Stop()
|
||||
|
||||
time.Sleep(syncDelay)
|
||||
|
||||
require.Empty(t, client.SubscribedTracks()[publisher.ID()])
|
||||
}
|
||||
|
||||
func Test_RenegotiationWithDifferentCodecs(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, finish := setupSingleNodeTest("TestRenegotiationWithDifferentCodecs")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
c2 := createRTCClient("c2", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1, c2)
|
||||
|
||||
// publish a vp8 video track and ensure clients receive it ok
|
||||
t1, err := c1.AddStaticTrack("audio/opus", "audio", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer t1.Stop()
|
||||
t2, err := c1.AddStaticTrack("video/vp8", "video", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer t2.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c2.SubscribedTracks()) == 0 {
|
||||
return "c2 was not subscribed to anything"
|
||||
}
|
||||
// should have received two tracks
|
||||
if len(c2.SubscribedTracks()[c1.ID()]) != 2 {
|
||||
return "c2 was not subscribed to tracks from c1"
|
||||
}
|
||||
|
||||
tracks := c2.SubscribedTracks()[c1.ID()]
|
||||
for _, t := range tracks {
|
||||
if mime.IsMimeTypeStringVP8(t.Codec().MimeType) {
|
||||
return ""
|
||||
|
||||
}
|
||||
}
|
||||
return "did not receive track with vp8"
|
||||
})
|
||||
|
||||
t3, err := c1.AddStaticTrackWithCodec(webrtc.RTPCodecCapability{
|
||||
MimeType: "video/h264",
|
||||
ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
||||
}, "videoscreen", "screen")
|
||||
defer t3.Stop()
|
||||
require.NoError(t, err)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c2.SubscribedTracks()) == 0 {
|
||||
return "c2's not subscribed to anything"
|
||||
}
|
||||
// should have received three tracks
|
||||
if len(c2.SubscribedTracks()[c1.ID()]) != 3 {
|
||||
return "c2's not subscribed to 3 tracks from c1"
|
||||
}
|
||||
|
||||
var vp8Found, h264Found bool
|
||||
tracks := c2.SubscribedTracks()[c1.ID()]
|
||||
for _, t := range tracks {
|
||||
if mime.IsMimeTypeStringVP8(t.Codec().MimeType) {
|
||||
vp8Found = true
|
||||
} else if mime.IsMimeTypeStringH264(t.Codec().MimeType) {
|
||||
h264Found = true
|
||||
}
|
||||
}
|
||||
if !vp8Found {
|
||||
return "did not receive track with vp8"
|
||||
}
|
||||
if !h264Found {
|
||||
return "did not receive track with h264"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
func TestSingleNodeRoomList(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
_, finish := setupSingleNodeTest("TestSingleNodeRoomList")
|
||||
defer finish()
|
||||
|
||||
roomServiceListRoom(t)
|
||||
}
|
||||
|
||||
func TestSingleNodeUpdateParticipant(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
_, finish := setupSingleNodeTest("TestSingleNodeRoomList")
|
||||
defer finish()
|
||||
|
||||
adminCtx := contextWithToken(adminRoomToken(testRoom))
|
||||
t.Run("update nonexistent participant", func(t *testing.T) {
|
||||
_, err := roomClient.UpdateParticipant(adminCtx, &livekit.UpdateParticipantRequest{
|
||||
Room: testRoom,
|
||||
Identity: "nonexistent",
|
||||
Permission: &livekit.ParticipantPermission{
|
||||
CanPublish: true,
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
var twErr twirp.Error
|
||||
require.True(t, errors.As(err, &twErr))
|
||||
// Note: for Cloud this would return 404, currently we are not able to differentiate between
|
||||
// non-existent participant vs server being unavailable in OSS
|
||||
require.Equal(t, twirp.Unavailable, twErr.Code())
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure that CORS headers are returned
|
||||
func TestSingleNodeCORS(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
s, finish := setupSingleNodeTest("TestSingleNodeCORS")
|
||||
defer finish()
|
||||
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d", s.HTTPPort()), nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "bearer xyz")
|
||||
req.Header.Set("Origin", "testhost.com")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "testhost.com", res.Header.Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
func TestSingleNodeDoubleSlash(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
s, finish := setupSingleNodeTest("TestSingleNodeDoubleSlash")
|
||||
defer finish()
|
||||
// client contains trailing slash in URL, causing path to contain double //
|
||||
// without our middleware, this would cause a 302 redirect
|
||||
roomClient = livekit.NewRoomServiceJSONClient(fmt.Sprintf("http://localhost:%d/", s.HTTPPort()), &http.Client{})
|
||||
_, err := roomClient.ListRooms(contextWithToken(listRoomToken()), &livekit.ListRoomsRequest{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPingPong(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
_, finish := setupSingleNodeTest("TestPingPong")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
require.NoError(t, c1.SendPing())
|
||||
require.Eventually(t, func() bool {
|
||||
return c1.PongReceivedAt() > 0
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestSingleNodeJoinAfterClose(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, finish := setupSingleNodeTest("TestJoinAfterClose")
|
||||
defer finish()
|
||||
|
||||
scenarioJoinClosedRoom(t)
|
||||
}
|
||||
|
||||
func TestSingleNodeCloseNonRTCRoom(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, finish := setupSingleNodeTest("closeNonRTCRoom")
|
||||
defer finish()
|
||||
|
||||
closeNonRTCRoom(t)
|
||||
}
|
||||
|
||||
func TestAutoCreate(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
disableAutoCreate := func(conf *config.Config) {
|
||||
conf.Room.AutoCreate = false
|
||||
}
|
||||
t.Run("cannot join if room isn't created", func(t *testing.T) {
|
||||
s := createSingleNodeServer(disableAutoCreate)
|
||||
go func() {
|
||||
if err := s.Start(); err != nil {
|
||||
logger.Errorw("server returned error", err)
|
||||
}
|
||||
}()
|
||||
defer s.Stop(true)
|
||||
|
||||
waitForServerToStart(s)
|
||||
|
||||
token := joinToken(testRoom, "start-before-create", nil)
|
||||
_, err := testclient.NewWebSocketConn(fmt.Sprintf("ws://localhost:%d", defaultServerPort), token, nil)
|
||||
require.Error(t, err)
|
||||
|
||||
// second join should also fail
|
||||
token = joinToken(testRoom, "start-before-create-2", nil)
|
||||
_, err = testclient.NewWebSocketConn(fmt.Sprintf("ws://localhost:%d", defaultServerPort), token, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("join with explicit createRoom", func(t *testing.T) {
|
||||
s := createSingleNodeServer(disableAutoCreate)
|
||||
go func() {
|
||||
if err := s.Start(); err != nil {
|
||||
logger.Errorw("server returned error", err)
|
||||
}
|
||||
}()
|
||||
defer s.Stop(true)
|
||||
|
||||
waitForServerToStart(s)
|
||||
|
||||
// explicitly create
|
||||
_, err := roomClient.CreateRoom(contextWithToken(createRoomToken()), &livekit.CreateRoomRequest{Name: testRoom})
|
||||
require.NoError(t, err)
|
||||
|
||||
c1 := createRTCClient("join-after-create", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
c1.Stop()
|
||||
})
|
||||
}
|
||||
|
||||
// don't give user subscribe permissions initially, and ensure autosubscribe is triggered afterwards
|
||||
func TestSingleNodeUpdateSubscriptionPermissions(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
_, finish := setupSingleNodeTest("TestSingleNodeUpdateSubscriptionPermissions")
|
||||
defer finish()
|
||||
|
||||
pub := createRTCClient("pub", defaultServerPort, nil)
|
||||
grant := &auth.VideoGrant{RoomJoin: true, Room: testRoom}
|
||||
grant.SetCanSubscribe(false)
|
||||
at := auth.NewAccessToken(testApiKey, testApiSecret).
|
||||
AddGrant(grant).
|
||||
SetIdentity("sub")
|
||||
token, err := at.ToJWT()
|
||||
require.NoError(t, err)
|
||||
sub := createRTCClientWithToken(token, defaultServerPort, nil)
|
||||
|
||||
waitUntilConnected(t, pub, sub)
|
||||
|
||||
writers := publishTracksForClients(t, pub)
|
||||
defer stopWriters(writers...)
|
||||
|
||||
// wait sub receives tracks
|
||||
testutils.WithTimeout(t, func() string {
|
||||
pubRemote := sub.GetRemoteParticipant(pub.ID())
|
||||
if pubRemote == nil {
|
||||
return "could not find remote publisher"
|
||||
}
|
||||
if len(pubRemote.Tracks) != 2 {
|
||||
return "did not receive metadata for published tracks"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// set permissions out of band
|
||||
ctx := contextWithToken(adminRoomToken(testRoom))
|
||||
_, err = roomClient.UpdateParticipant(ctx, &livekit.UpdateParticipantRequest{
|
||||
Room: testRoom,
|
||||
Identity: "sub",
|
||||
Permission: &livekit.ParticipantPermission{
|
||||
CanSubscribe: true,
|
||||
CanPublish: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
tracks := sub.SubscribedTracks()[pub.ID()]
|
||||
if len(tracks) == 2 {
|
||||
return ""
|
||||
} else {
|
||||
return fmt.Sprintf("expected 2 tracks subscribed, actual: %d", len(tracks))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeviceCodecOverride checks that codecs that are incompatible with a device is not
|
||||
// negotiated by the server
|
||||
func TestDeviceCodecOverride(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, finish := setupSingleNodeTest("TestDeviceCodecOverride")
|
||||
defer finish()
|
||||
|
||||
// simulate device that isn't compatible with H.264
|
||||
c1 := createRTCClient("c1", defaultServerPort, &testclient.Options{
|
||||
ClientInfo: &livekit.ClientInfo{
|
||||
Os: "android",
|
||||
DeviceModel: "Xiaomi 2201117TI",
|
||||
},
|
||||
})
|
||||
defer c1.Stop()
|
||||
waitUntilConnected(t, c1)
|
||||
|
||||
// it doesn't really matter what the codec set here is, uses default Pion MediaEngine codecs
|
||||
tw, err := c1.AddStaticTrack("video/h264", "video", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer stopWriters(tw)
|
||||
|
||||
// wait for server to receive track
|
||||
require.Eventually(t, func() bool {
|
||||
return c1.LastAnswer() != nil
|
||||
}, waitTimeout, waitTick, "did not receive answer")
|
||||
|
||||
sd := webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: c1.LastAnswer().SDP,
|
||||
}
|
||||
answer, err := sd.Unmarshal()
|
||||
require.NoError(t, err)
|
||||
|
||||
// video and data channel
|
||||
require.Len(t, answer.MediaDescriptions, 2)
|
||||
var desc *sdp.MediaDescription
|
||||
for _, md := range answer.MediaDescriptions {
|
||||
if md.MediaName.Media == "video" {
|
||||
desc = md
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, desc)
|
||||
hasSeenVP8 := false
|
||||
for _, a := range desc.Attributes {
|
||||
if a.Key == "rtpmap" {
|
||||
require.NotContains(t, a.Value, mime.MimeTypeCodecH264.String(), "should not contain H264 codec")
|
||||
if strings.Contains(a.Value, mime.MimeTypeCodecVP8.String()) {
|
||||
hasSeenVP8 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, hasSeenVP8, "should have seen VP8 codec in SDP")
|
||||
}
|
||||
|
||||
func TestSubscribeToCodecUnsupported(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
_, finish := setupSingleNodeTest("TestSubscribeToCodecUnsupported")
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
// create a client that doesn't support H264
|
||||
c2 := createRTCClient("c2", defaultServerPort, &testclient.Options{
|
||||
AutoSubscribe: true,
|
||||
DisabledCodecs: []webrtc.RTPCodecCapability{
|
||||
{MimeType: "video/H264"},
|
||||
},
|
||||
})
|
||||
waitUntilConnected(t, c1, c2)
|
||||
|
||||
// publish a vp8 video track and ensure c2 receives it ok
|
||||
t1, err := c1.AddStaticTrack("audio/opus", "audio", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer t1.Stop()
|
||||
t2, err := c1.AddStaticTrack("video/vp8", "video", "webcam")
|
||||
require.NoError(t, err)
|
||||
defer t2.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c2.SubscribedTracks()) == 0 {
|
||||
return "c2 was not subscribed to anything"
|
||||
}
|
||||
// should have received two tracks
|
||||
if len(c2.SubscribedTracks()[c1.ID()]) != 2 {
|
||||
return "c2 was not subscribed to tracks from c1"
|
||||
}
|
||||
|
||||
tracks := c2.SubscribedTracks()[c1.ID()]
|
||||
for _, t := range tracks {
|
||||
if mime.IsMimeTypeStringVP8(t.Codec().MimeType) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return "did not receive track with vp8"
|
||||
})
|
||||
require.Nil(t, c2.GetSubscriptionResponseAndClear())
|
||||
|
||||
// publish a h264 track and ensure c2 got subscription error
|
||||
t3, err := c1.AddStaticTrackWithCodec(webrtc.RTPCodecCapability{
|
||||
MimeType: "video/h264",
|
||||
ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
||||
}, "videoscreen", "screen")
|
||||
defer t3.Stop()
|
||||
require.NoError(t, err)
|
||||
|
||||
var h264TrackID string
|
||||
require.Eventually(t, func() bool {
|
||||
remoteC1 := c2.GetRemoteParticipant(c1.ID())
|
||||
require.NotNil(t, remoteC1)
|
||||
for _, track := range remoteC1.Tracks {
|
||||
if mime.IsMimeTypeStringH264(track.MimeType) {
|
||||
h264TrackID = track.Sid
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, time.Second, 10*time.Millisecond, "did not receive track info with h264")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
sr := c2.GetSubscriptionResponseAndClear()
|
||||
if sr == nil {
|
||||
return false
|
||||
}
|
||||
require.Equal(t, h264TrackID, sr.TrackSid)
|
||||
require.Equal(t, livekit.SubscriptionError_SE_CODEC_UNSUPPORTED, sr.Err)
|
||||
return true
|
||||
}, 5*time.Second, 10*time.Millisecond, "did not receive subscription response")
|
||||
|
||||
// publish another vp8 track again, ensure the transport recovered by sfu and c2 can receive it
|
||||
t4, err := c1.AddStaticTrack("video/vp8", "video2", "webcam2")
|
||||
require.NoError(t, err)
|
||||
defer t4.Stop()
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if len(c2.SubscribedTracks()) == 0 {
|
||||
return "c2 was not subscribed to anything"
|
||||
}
|
||||
// should have received two tracks
|
||||
if len(c2.SubscribedTracks()[c1.ID()]) != 3 {
|
||||
return "c2 was not subscribed to tracks from c1"
|
||||
}
|
||||
|
||||
var vp8Count int
|
||||
tracks := c2.SubscribedTracks()[c1.ID()]
|
||||
for _, t := range tracks {
|
||||
if mime.IsMimeTypeStringVP8(t.Codec().MimeType) {
|
||||
vp8Count++
|
||||
}
|
||||
}
|
||||
if vp8Count == 2 {
|
||||
return ""
|
||||
}
|
||||
return "did not 2 receive track with vp8"
|
||||
})
|
||||
require.Nil(t, c2.GetSubscriptionResponseAndClear())
|
||||
}
|
||||
|
||||
func TestDataPublishSlowSubscriber(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
return
|
||||
}
|
||||
|
||||
dataChannelSlowThreshold := 21024
|
||||
|
||||
logger.Infow("----------------STARTING TEST----------------", "test", t.Name())
|
||||
s := createSingleNodeServer(func(c *config.Config) {
|
||||
c.RTC.DatachannelSlowThreshold = dataChannelSlowThreshold
|
||||
})
|
||||
go func() {
|
||||
if err := s.Start(); err != nil {
|
||||
logger.Errorw("server returned error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
waitForServerToStart(s)
|
||||
|
||||
defer func() {
|
||||
s.Stop(true)
|
||||
logger.Infow("----------------FINISHING TEST----------------", "test", t.Name())
|
||||
}()
|
||||
|
||||
pub := createRTCClient("pub", defaultServerPort, nil)
|
||||
fastSub := createRTCClient("fastSub", defaultServerPort, nil)
|
||||
slowSubNotDrop := createRTCClient("slowSubNotDrop", defaultServerPort, nil)
|
||||
slowSubDrop := createRTCClient("slowSubDrop", defaultServerPort, nil)
|
||||
waitUntilConnected(t, pub, fastSub, slowSubDrop, slowSubNotDrop)
|
||||
defer func() {
|
||||
pub.Stop()
|
||||
fastSub.Stop()
|
||||
slowSubNotDrop.Stop()
|
||||
slowSubDrop.Stop()
|
||||
}()
|
||||
|
||||
// no data should be dropped for fast subscriber
|
||||
var fastDataIndex atomic.Uint64
|
||||
fastSub.OnDataReceived = func(data []byte, sid string) {
|
||||
idx := binary.BigEndian.Uint64(data[len(data)-8:])
|
||||
require.Equal(t, fastDataIndex.Load()+1, idx)
|
||||
fastDataIndex.Store(idx)
|
||||
}
|
||||
|
||||
// no data should be dropped for slow subscriber that is above threshold
|
||||
var slowNoDropDataIndex atomic.Uint64
|
||||
var drainSlowSubNotDrop atomic.Bool
|
||||
slowNoDropReader := testclient.NewDataChannelReader(dataChannelSlowThreshold * 2)
|
||||
slowSubNotDrop.OnDataReceived = func(data []byte, sid string) {
|
||||
idx := binary.BigEndian.Uint64(data[len(data)-8:])
|
||||
require.Equal(t, slowNoDropDataIndex.Load()+1, idx)
|
||||
slowNoDropDataIndex.Store(idx)
|
||||
if !drainSlowSubNotDrop.Load() {
|
||||
slowNoDropReader.Read(data, sid)
|
||||
}
|
||||
}
|
||||
|
||||
// data should be dropped for slow subscriber that is below threshold
|
||||
var slowDropDataIndex atomic.Uint64
|
||||
dropped := make(chan struct{})
|
||||
slowDropReader := testclient.NewDataChannelReader(dataChannelSlowThreshold / 2)
|
||||
slowSubDrop.OnDataReceived = func(data []byte, sid string) {
|
||||
select {
|
||||
case <-dropped:
|
||||
return
|
||||
default:
|
||||
}
|
||||
idx := binary.BigEndian.Uint64(data[len(data)-8:])
|
||||
if idx != slowDropDataIndex.Load()+1 {
|
||||
close(dropped)
|
||||
}
|
||||
slowDropDataIndex.Store(idx)
|
||||
slowDropReader.Read(data, sid)
|
||||
}
|
||||
|
||||
// publisher sends data as fast as possible, it will block by the slowest subscriber above the slow threshold
|
||||
var (
|
||||
blocked atomic.Bool
|
||||
stopWrite atomic.Bool
|
||||
writeIdx atomic.Uint64
|
||||
)
|
||||
writeStopped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writeStopped)
|
||||
var i int
|
||||
buf := make([]byte, 100)
|
||||
for !stopWrite.Load() {
|
||||
i++
|
||||
binary.BigEndian.PutUint64(buf[len(buf)-8:], uint64(i))
|
||||
if err := pub.PublishData(buf, livekit.DataPacket_RELIABLE); err != nil {
|
||||
if errors.Is(err, datachannel.ErrDataDroppedBySlowReader) {
|
||||
blocked.Store(true)
|
||||
i--
|
||||
continue
|
||||
} else {
|
||||
t.Log("error writing", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
writeIdx.Store(uint64(i))
|
||||
}
|
||||
}()
|
||||
|
||||
<-dropped
|
||||
|
||||
time.Sleep(time.Second)
|
||||
blocked.Store(false)
|
||||
require.Eventually(t, func() bool { return blocked.Load() }, 30*time.Second, 100*time.Millisecond)
|
||||
stopWrite.Store(true)
|
||||
<-writeStopped
|
||||
drainSlowSubNotDrop.Store(true)
|
||||
require.Eventually(t, func() bool {
|
||||
return writeIdx.Load() == fastDataIndex.Load() &&
|
||||
writeIdx.Load() == slowNoDropDataIndex.Load()
|
||||
}, 10*time.Second, 50*time.Millisecond, "writeIdx %d, fast %d, slowNoDrop %d", writeIdx.Load(), fastDataIndex.Load(), slowNoDropDataIndex.Load())
|
||||
}
|
||||
|
||||
func TestFireTrackBySdp(t *testing.T) {
|
||||
_, finish := setupSingleNodeTest("TestFireTrackBySdp")
|
||||
defer finish()
|
||||
|
||||
var cases = []struct {
|
||||
name string
|
||||
codecs []webrtc.RTPCodecCapability
|
||||
pubSDK livekit.ClientInfo_SDK
|
||||
}{
|
||||
{
|
||||
name: "js client could pub a/v tracks",
|
||||
codecs: []webrtc.RTPCodecCapability{
|
||||
{MimeType: "video/H264"},
|
||||
{MimeType: "audio/opus"},
|
||||
},
|
||||
pubSDK: livekit.ClientInfo_JS,
|
||||
},
|
||||
{
|
||||
name: "go client could pub audio tracks",
|
||||
codecs: []webrtc.RTPCodecCapability{
|
||||
{MimeType: "audio/opus"},
|
||||
},
|
||||
pubSDK: livekit.ClientInfo_GO,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
codecs, sdk := c.codecs, c.pubSDK
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
c1 := createRTCClient(c.name+"_c1", defaultServerPort, &testclient.Options{
|
||||
ClientInfo: &livekit.ClientInfo{
|
||||
Sdk: sdk,
|
||||
},
|
||||
})
|
||||
c2 := createRTCClient(c.name+"_c2", defaultServerPort, &testclient.Options{
|
||||
AutoSubscribe: true,
|
||||
ClientInfo: &livekit.ClientInfo{
|
||||
Sdk: livekit.ClientInfo_JS,
|
||||
},
|
||||
})
|
||||
waitUntilConnected(t, c1, c2)
|
||||
defer func() {
|
||||
c1.Stop()
|
||||
c2.Stop()
|
||||
}()
|
||||
|
||||
// publish tracks and don't write any packets
|
||||
for _, codec := range codecs {
|
||||
_, err := c1.AddStaticTrackWithCodec(codec, codec.MimeType, codec.MimeType, testclient.AddTrackNoWriter())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(c2.SubscribedTracks()[c1.ID()]) == len(codecs)
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
|
||||
var found int
|
||||
for _, pubTrack := range c1.GetPublishedTrackIDs() {
|
||||
t.Log("pub track", pubTrack)
|
||||
tracks := c2.SubscribedTracks()[c1.ID()]
|
||||
for _, track := range tracks {
|
||||
t.Log("sub track", track.ID(), track.Codec())
|
||||
if track.Codec().PayloadType == 0 && track.ID() == pubTrack {
|
||||
found++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Equal(t, len(codecs), found)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
"github.com/livekit/protocol/webhook"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/service"
|
||||
"github.com/livekit/livekit-server/pkg/testutils"
|
||||
)
|
||||
|
||||
func TestWebhooks(t *testing.T) {
|
||||
server, ts, finish, err := setupServerWithWebhook()
|
||||
require.NoError(t, err)
|
||||
defer finish()
|
||||
|
||||
c1 := createRTCClient("c1", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c1)
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ts.GetEvent(webhook.EventRoomStarted) == nil {
|
||||
return "did not receive RoomStarted"
|
||||
}
|
||||
if ts.GetEvent(webhook.EventParticipantJoined) == nil {
|
||||
return "did not receive ParticipantJoined"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// first participant join should have started the room
|
||||
started := ts.GetEvent(webhook.EventRoomStarted)
|
||||
require.Equal(t, testRoom, started.Room.Name)
|
||||
require.NotEmpty(t, started.Id)
|
||||
require.Greater(t, started.CreatedAt, time.Now().Unix()-100)
|
||||
require.GreaterOrEqual(t, time.Now().Unix(), started.CreatedAt)
|
||||
joined := ts.GetEvent(webhook.EventParticipantJoined)
|
||||
require.Equal(t, "c1", joined.Participant.Identity)
|
||||
ts.ClearEvents()
|
||||
|
||||
// another participant joins
|
||||
c2 := createRTCClient("c2", defaultServerPort, nil)
|
||||
waitUntilConnected(t, c2)
|
||||
defer c2.Stop()
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ts.GetEvent(webhook.EventParticipantJoined) == nil {
|
||||
return "did not receive ParticipantJoined"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
joined = ts.GetEvent(webhook.EventParticipantJoined)
|
||||
require.Equal(t, "c2", joined.Participant.Identity)
|
||||
ts.ClearEvents()
|
||||
|
||||
// track published
|
||||
writers := publishTracksForClients(t, c1)
|
||||
defer stopWriters(writers...)
|
||||
testutils.WithTimeout(t, func() string {
|
||||
ev := ts.GetEvent(webhook.EventTrackPublished)
|
||||
if ev == nil {
|
||||
return "did not receive TrackPublished"
|
||||
}
|
||||
require.NotNil(t, ev.Track, "TrackPublished did not include trackInfo")
|
||||
require.Equal(t, string(c1.ID()), ev.Participant.Sid)
|
||||
return ""
|
||||
})
|
||||
ts.ClearEvents()
|
||||
|
||||
// first participant leaves
|
||||
c1.Stop()
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ts.GetEvent(webhook.EventParticipantLeft) == nil {
|
||||
return "did not receive ParticipantLeft"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
left := ts.GetEvent(webhook.EventParticipantLeft)
|
||||
require.Equal(t, "c1", left.Participant.Identity)
|
||||
ts.ClearEvents()
|
||||
|
||||
// room closed
|
||||
rm := server.RoomManager().GetRoom(context.Background(), testRoom)
|
||||
rm.Close(types.ParticipantCloseReasonNone)
|
||||
testutils.WithTimeout(t, func() string {
|
||||
if ts.GetEvent(webhook.EventRoomFinished) == nil {
|
||||
return "did not receive RoomFinished"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
require.Equal(t, testRoom, ts.GetEvent(webhook.EventRoomFinished).Room.Name)
|
||||
}
|
||||
|
||||
func setupServerWithWebhook() (server *service.LivekitServer, testServer *webhookTestServer, finishFunc func(), err error) {
|
||||
conf, err := config.NewConfig("", true, nil, nil)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not create config: %v", err))
|
||||
}
|
||||
conf.WebHook.URLs = []string{"http://localhost:7890"}
|
||||
conf.WebHook.APIKey = testApiKey
|
||||
conf.Keys = map[string]string{testApiKey: testApiSecret}
|
||||
|
||||
testServer = newTestServer(":7890")
|
||||
if err = testServer.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
currentNode, err := routing.NewLocalNode(conf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
currentNode.SetNodeID(livekit.NodeID(guid.New(nodeID1)))
|
||||
|
||||
server, err = service.InitializeServer(conf, currentNode)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := server.Start(); err != nil {
|
||||
logger.Errorw("server returned error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
waitForServerToStart(server)
|
||||
|
||||
finishFunc = func() {
|
||||
server.Stop(true)
|
||||
testServer.Stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type webhookTestServer struct {
|
||||
server *http.Server
|
||||
events map[string]*livekit.WebhookEvent
|
||||
lock sync.Mutex
|
||||
provider auth.KeyProvider
|
||||
}
|
||||
|
||||
func newTestServer(addr string) *webhookTestServer {
|
||||
s := &webhookTestServer{
|
||||
events: make(map[string]*livekit.WebhookEvent),
|
||||
provider: auth.NewFileBasedKeyProviderFromMap(map[string]string{testApiKey: testApiSecret}),
|
||||
}
|
||||
s.server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *webhookTestServer) ServeHTTP(_ http.ResponseWriter, r *http.Request) {
|
||||
data, err := webhook.Receive(r, s.provider)
|
||||
if err != nil {
|
||||
logger.Errorw("could not receive webhook", err)
|
||||
return
|
||||
}
|
||||
|
||||
event := livekit.WebhookEvent{}
|
||||
if err = protojson.Unmarshal(data, &event); err != nil {
|
||||
logger.Errorw("could not unmarshal event", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.lock.Lock()
|
||||
s.events[event.Event] = &event
|
||||
s.lock.Unlock()
|
||||
}
|
||||
|
||||
func (s *webhookTestServer) GetEvent(name string) *livekit.WebhookEvent {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
return s.events[name]
|
||||
}
|
||||
|
||||
func (s *webhookTestServer) ClearEvents() {
|
||||
s.lock.Lock()
|
||||
s.events = make(map[string]*livekit.WebhookEvent)
|
||||
s.lock.Unlock()
|
||||
}
|
||||
|
||||
func (s *webhookTestServer) Start() error {
|
||||
l, err := net.Listen("tcp", s.server.Addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go s.server.Serve(l)
|
||||
|
||||
// wait for webhook server to start
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutils.ConnectTimeout)
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.New("could not start webhook server after timeout")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
// ensure we can connect to it
|
||||
res, err := http.Get(fmt.Sprintf("http://localhost%s", s.server.Addr))
|
||||
if err == nil && res.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *webhookTestServer) Stop() {
|
||||
_ = s.server.Shutdown(context.Background())
|
||||
}
|
||||
Reference in New Issue
Block a user