mirror of
https://github.com/openlibrecommunity/olcrtc.git
synced 2026-05-26 23:19:47 +00:00
Decompose the monolithic internal/provider/telemost package into two orthogonal layers: engine/goolom (Yandex proprietary SFU wire protocol — WebSocket signaling, dual pub/sub PeerConnections, DataChannel, telemetry) and auth/telemost (HTTP connection-info fetch → engine.Credentials). Add engine.Config.Refresh callback so Goolom can obtain fresh peerID and credentials on every reconnect without a direct dependency on the auth package. engine_adapter wires the Refresh closure from authProvider.Issue. Delete internal/provider/ entirely (telemost was the last tenant) and remove the now-obsolete provider_adapter + its test from builtin. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
323 lines
9.8 KiB
Go
323 lines
9.8 KiB
Go
// Package goolom implements an engine.Session backed by the Goolom SFU
|
|
// signaling protocol. Goolom is the proprietary SFU developed for Yandex
|
|
// Telemost; the on-wire protocol — capabilities offer, separated subscriber
|
|
// and publisher PeerConnections, ack/pong keepalive, slots-based subscribe
|
|
// model — is what this engine speaks.
|
|
//
|
|
// HTTP auth (room-info lookup, telemetry referer, etc.) lives in the auth
|
|
// package; this engine consumes a media-server WebSocket URL plus the
|
|
// peer/room/credentials tuple supplied as engine.Config.
|
|
package goolom
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/openlibrecommunity/olcrtc/internal/engine"
|
|
"github.com/pion/webrtc/v4"
|
|
)
|
|
|
|
const (
|
|
realDataChannelMessageLimit = 12288
|
|
defaultSendDelayLow = 2 * time.Millisecond
|
|
defaultSendDelayMax = 12 * time.Millisecond
|
|
defaultTelemetryInterval = 20 * time.Second
|
|
defaultSendQueueSize = 5000
|
|
defaultBufferHighWaterMark = 512 * 1024
|
|
defaultSendQueueCapHard = 4000
|
|
|
|
wsReadTimeout = 60 * time.Second
|
|
wsHandshakeTimeout = 15 * time.Second
|
|
|
|
keyUID = "uid"
|
|
keyDescription = "description"
|
|
keyPcSeq = "pcSeq"
|
|
keyName = "name"
|
|
stateTerminated = "terminated"
|
|
|
|
credentialKeyRoomID = "roomID"
|
|
credentialKeyCredentials = "credentials"
|
|
credentialKeyRoomURL = "roomURL"
|
|
credentialKeyTelemetryReferer = "telemetryReferer"
|
|
)
|
|
|
|
var (
|
|
// ErrDataChannelTimeout is returned when the DataChannel fails to open in time.
|
|
ErrDataChannelTimeout = errors.New("datachannel timeout")
|
|
// ErrDataChannelNotReady is returned when send is called before the DataChannel is open.
|
|
ErrDataChannelNotReady = errors.New("datachannel not ready")
|
|
// ErrSendQueueClosed is returned when send is called after Close.
|
|
ErrSendQueueClosed = errors.New("send queue closed")
|
|
// ErrSendQueueTimeout is returned when the send queue cannot accept new data in time.
|
|
ErrSendQueueTimeout = errors.New("send queue timeout")
|
|
// ErrSessionClosed is returned when the session is closed mid-operation.
|
|
ErrSessionClosed = errors.New("session closed")
|
|
// ErrPeerClosed is returned when the peer is closed mid-operation.
|
|
ErrPeerClosed = errors.New("peer closed")
|
|
// ErrSubscriberMediaTimeout is returned when the subscriber media is not ready in time.
|
|
ErrSubscriberMediaTimeout = errors.New("subscriber media timeout")
|
|
// ErrPublisherNotInitialized is returned when the publisher PC is not set up.
|
|
ErrPublisherNotInitialized = errors.New("publisher peer connection not initialized")
|
|
// ErrURLRequired is returned when no media-server WebSocket URL was supplied.
|
|
ErrURLRequired = errors.New("goolom media server URL required")
|
|
// ErrRoomIDRequired is returned when no room ID was supplied.
|
|
ErrRoomIDRequired = errors.New("goolom room ID required")
|
|
// ErrPeerIDRequired is returned when no peer ID was supplied.
|
|
ErrPeerIDRequired = errors.New("goolom peer ID required")
|
|
// ErrNoRefresh is returned when reconnect is attempted without a refresh callback.
|
|
ErrNoRefresh = errors.New("goolom reconnect: no refresh callback supplied")
|
|
)
|
|
|
|
// TrafficShape controls outgoing data-channel pacing.
|
|
type TrafficShape struct {
|
|
MaxMessageSize int
|
|
MinDelay time.Duration
|
|
MaxDelay time.Duration
|
|
}
|
|
|
|
// Session is the Goolom engine handle.
|
|
type Session struct {
|
|
name string
|
|
mediaServerURL string
|
|
peerID string
|
|
roomID string
|
|
credentials string
|
|
roomURL string // referer for telemetry — opaque to the engine
|
|
telemetryReferer string
|
|
refresh func(ctx context.Context) (engine.Credentials, error)
|
|
|
|
ws *websocket.Conn
|
|
wsMu sync.Mutex
|
|
pcSub *webrtc.PeerConnection
|
|
pcPub *webrtc.PeerConnection
|
|
dc *webrtc.DataChannel
|
|
|
|
onData func([]byte)
|
|
onReconnect func(*webrtc.DataChannel)
|
|
shouldReconnect func() bool
|
|
onEnded func(string)
|
|
|
|
reconnectCh chan struct{}
|
|
closeCh chan struct{}
|
|
keepAliveCh chan struct{}
|
|
telemetryCh chan struct{}
|
|
sessionCloseCh chan struct{}
|
|
lastReconnect time.Time
|
|
reconnectCount int
|
|
sessionMu sync.Mutex
|
|
|
|
sendQueue chan []byte
|
|
sendQueueClosed atomic.Bool
|
|
closed atomic.Bool
|
|
reconnecting atomic.Bool
|
|
telemetryActive atomic.Bool
|
|
|
|
ackMu sync.Mutex
|
|
ackWaiters map[string]chan struct{}
|
|
|
|
trafficShape TrafficShape
|
|
|
|
videoTrackMu sync.RWMutex
|
|
videoTracks []webrtc.TrackLocal
|
|
onVideoTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver)
|
|
subscriberReady atomic.Bool
|
|
publisherReady atomic.Bool
|
|
subscriberConn chan struct{}
|
|
publisherConn chan struct{}
|
|
wg sync.WaitGroup
|
|
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// New creates a new Goolom engine session.
|
|
//
|
|
// cfg.URL is the media server WebSocket URL. cfg.Token carries the peer ID.
|
|
// cfg.Extra carries the rest of the room tuple: roomID, credentials, and an
|
|
// optional roomURL / telemetryReferer string the engine uses verbatim as the
|
|
// Referer header for telemetry posts.
|
|
func New(_ context.Context, cfg engine.Config) (engine.Session, error) {
|
|
if cfg.URL == "" {
|
|
return nil, ErrURLRequired
|
|
}
|
|
peerID := cfg.Token
|
|
if peerID == "" {
|
|
return nil, ErrPeerIDRequired
|
|
}
|
|
roomID := ""
|
|
credentials := ""
|
|
roomURL := ""
|
|
telemetryReferer := ""
|
|
if cfg.Extra != nil {
|
|
roomID = cfg.Extra[credentialKeyRoomID]
|
|
credentials = cfg.Extra[credentialKeyCredentials]
|
|
roomURL = cfg.Extra[credentialKeyRoomURL]
|
|
telemetryReferer = cfg.Extra[credentialKeyTelemetryReferer]
|
|
}
|
|
if roomID == "" {
|
|
return nil, ErrRoomIDRequired
|
|
}
|
|
if telemetryReferer == "" {
|
|
telemetryReferer = roomURL
|
|
}
|
|
|
|
return &Session{
|
|
name: cfg.Name,
|
|
mediaServerURL: cfg.URL,
|
|
peerID: peerID,
|
|
roomID: roomID,
|
|
credentials: credentials,
|
|
roomURL: roomURL,
|
|
telemetryReferer: telemetryReferer,
|
|
refresh: cfg.Refresh,
|
|
onData: cfg.OnData,
|
|
reconnectCh: make(chan struct{}, 1),
|
|
closeCh: make(chan struct{}),
|
|
keepAliveCh: make(chan struct{}),
|
|
sessionCloseCh: make(chan struct{}),
|
|
telemetryCh: make(chan struct{}, 1),
|
|
sendQueue: make(chan []byte, defaultSendQueueSize),
|
|
ackWaiters: make(map[string]chan struct{}),
|
|
subscriberConn: make(chan struct{}),
|
|
publisherConn: make(chan struct{}),
|
|
trafficShape: TrafficShape{
|
|
MaxMessageSize: realDataChannelMessageLimit,
|
|
MinDelay: defaultSendDelayLow,
|
|
MaxDelay: defaultSendDelayMax,
|
|
},
|
|
httpClient: nil,
|
|
}, nil
|
|
}
|
|
|
|
// Capabilities reports what this engine can do.
|
|
func (s *Session) Capabilities() engine.Capabilities {
|
|
return engine.Capabilities{ByteStream: true, VideoTrack: true}
|
|
}
|
|
|
|
// SetTrafficShape adjusts the outgoing data-channel pacing.
|
|
func (s *Session) SetTrafficShape(shape TrafficShape) {
|
|
if shape.MaxMessageSize <= 0 {
|
|
shape.MaxMessageSize = realDataChannelMessageLimit
|
|
}
|
|
if shape.MaxDelay < shape.MinDelay {
|
|
shape.MaxDelay = shape.MinDelay
|
|
}
|
|
s.trafficShape = shape
|
|
}
|
|
|
|
// Send queues data for transmission.
|
|
func (s *Session) Send(data []byte) error {
|
|
if s.dc == nil || s.dc.ReadyState() != webrtc.DataChannelStateOpen {
|
|
return ErrDataChannelNotReady
|
|
}
|
|
if s.sendQueueClosed.Load() {
|
|
return ErrSendQueueClosed
|
|
}
|
|
select {
|
|
case s.sendQueue <- data:
|
|
return nil
|
|
case <-time.After(50 * time.Millisecond):
|
|
return ErrSendQueueTimeout
|
|
}
|
|
}
|
|
|
|
// GetSendQueue returns the transmission queue.
|
|
func (s *Session) GetSendQueue() chan []byte { return s.sendQueue }
|
|
|
|
// GetBufferedAmount returns the WebRTC buffered amount.
|
|
func (s *Session) GetBufferedAmount() uint64 {
|
|
if s.dc != nil {
|
|
return s.dc.BufferedAmount()
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// SetEndedCallback sets the callback for connection termination.
|
|
func (s *Session) SetEndedCallback(cb func(string)) { s.onEnded = cb }
|
|
|
|
// SetReconnectCallback sets the callback for reconnection events.
|
|
func (s *Session) SetReconnectCallback(cb func(*webrtc.DataChannel)) { s.onReconnect = cb }
|
|
|
|
// SetShouldReconnect sets the policy for reconnection.
|
|
func (s *Session) SetShouldReconnect(fn func() bool) { s.shouldReconnect = fn }
|
|
|
|
// CanSend checks if data can be sent.
|
|
func (s *Session) CanSend() bool {
|
|
if s.onData == nil {
|
|
if s.hasLocalVideoTracks() {
|
|
return !s.closed.Load() && s.subscriberReady.Load() && s.publisherReady.Load()
|
|
}
|
|
return !s.closed.Load() && s.subscriberReady.Load()
|
|
}
|
|
if s.dc == nil || s.dc.ReadyState() != webrtc.DataChannelStateOpen {
|
|
return false
|
|
}
|
|
return len(s.sendQueue) < defaultSendQueueCapHard
|
|
}
|
|
|
|
// AddVideoTrack adds a video track to the publisher peer connection.
|
|
func (s *Session) AddVideoTrack(track webrtc.TrackLocal) error {
|
|
s.videoTrackMu.Lock()
|
|
s.videoTracks = append(s.videoTracks, track)
|
|
s.videoTrackMu.Unlock()
|
|
|
|
if s.pcPub == nil {
|
|
return nil
|
|
}
|
|
if _, err := s.pcPub.AddTrack(track); err != nil {
|
|
return fmt.Errorf("failed to add track: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetVideoTrackHandler registers a callback for remote video tracks.
|
|
func (s *Session) SetVideoTrackHandler(cb func(*webrtc.TrackRemote, *webrtc.RTPReceiver)) {
|
|
s.videoTrackMu.Lock()
|
|
defer s.videoTrackMu.Unlock()
|
|
s.onVideoTrack = cb
|
|
}
|
|
|
|
func (s *Session) hasLocalVideoTracks() bool {
|
|
s.videoTrackMu.RLock()
|
|
defer s.videoTrackMu.RUnlock()
|
|
return len(s.videoTracks) > 0
|
|
}
|
|
|
|
func (s *Session) videoTrackHandler() func(*webrtc.TrackRemote, *webrtc.RTPReceiver) {
|
|
s.videoTrackMu.RLock()
|
|
defer s.videoTrackMu.RUnlock()
|
|
return s.onVideoTrack
|
|
}
|
|
|
|
func (s *Session) attachPendingVideoTracks() error {
|
|
s.videoTrackMu.RLock()
|
|
defer s.videoTrackMu.RUnlock()
|
|
|
|
for _, track := range s.videoTracks {
|
|
if _, err := s.pcPub.AddTrack(track); err != nil {
|
|
return fmt.Errorf("add video track: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func closeSignal(ch chan struct{}) {
|
|
if ch == nil {
|
|
return
|
|
}
|
|
select {
|
|
case <-ch:
|
|
default:
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
func init() { //nolint:gochecknoinits // engine registration is the canonical Go pattern for plugins
|
|
engine.Register("goolom", New)
|
|
}
|