feat: extend SFU to forward video tracks and enforce MaxVideo limit

- setupOnTrack now handles both audio and video tracks via kind branch
  instead of returning early on non-audio tracks
- RTP forwarding works identically for both kinds; speaker detection
  only runs for audio tracks
- handleVoiceCamera enforces MaxVideo limit before DB update, rejecting
  with VIDEO_LIMIT error when the cap is reached
- Added TestHandleVoiceCamera_MaxVideoEnforced test
This commit is contained in:
jevb
2026-03-19 16:33:47 +01:00
parent 51033dab6d
commit b802532af3
2 changed files with 149 additions and 6 deletions
+49 -6
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/pion/webrtc/v4"
@@ -445,6 +446,28 @@ func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) {
return
}
// Enforce MaxVideo limit when enabling camera.
if p.Enabled {
room := h.GetVoiceRoom(voiceChID)
if room != nil {
cfg := room.Config()
if cfg.MaxVideo > 0 {
allTracks := room.GetTracks()
videoCount := 0
for _, vt := range allTracks {
if vt.Local != nil && strings.HasPrefix(vt.Local.ID(), "video-") {
videoCount++
}
}
if videoCount >= cfg.MaxVideo {
c.sendMsg(buildErrorMsg("VIDEO_LIMIT",
fmt.Sprintf("maximum %d video streams reached", cfg.MaxVideo)))
return
}
}
}
}
if err := h.db.UpdateVoiceCamera(c.userID, p.Enabled); err != nil {
slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg("INTERNAL", "failed to update camera state"))
@@ -683,25 +706,33 @@ func (h *Hub) setupOnTrack(c *Client, channelID int64) {
}
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
if track.Kind() != webrtc.RTPCodecTypeAudio {
// Determine kind from the remote track.
var kind string
switch track.Kind() {
case webrtc.RTPCodecTypeAudio:
kind = "audio"
case webrtc.RTPCodecTypeVideo:
kind = "video"
default:
return
}
slog.Info("SFU OnTrack",
"user_id", c.userID,
"channel_id", channelID,
"kind", kind,
"codec", track.Codec().MimeType,
)
// Create local track for fan-out using the remote track's codec.
local, err := webrtc.NewTrackLocalStaticRTP(
track.Codec().RTPCodecCapability,
fmt.Sprintf("audio-%d", c.userID),
fmt.Sprintf("%s-%d", kind, c.userID),
fmt.Sprintf("user-%d", c.userID),
)
if err != nil {
slog.Error("setupOnTrack NewTrackLocalStaticRTP",
"err", err, "user_id", c.userID)
"err", err, "user_id", c.userID, "kind", kind)
return
}
@@ -711,8 +742,8 @@ func (h *Hub) setupOnTrack(c *Client, channelID int64) {
}
// Store track on room.
room.SetTrack(c.userID, "audio", track, local)
vt := room.GetTrack(c.userID, "audio")
room.SetTrack(c.userID, kind, track, local)
vt := room.GetTrack(c.userID, kind)
// Collect other participant IDs (lock ordering: VoiceRoom.mu released before voiceMu).
participantIDs := room.ParticipantIDs()
@@ -749,6 +780,7 @@ func (h *Hub) setupOnTrack(c *Client, channelID int64) {
slog.Info("SFU track fan-out",
"from_user", c.userID,
"channel_id", channelID,
"kind", kind,
"participants", len(participantIDs),
"tracks_added", addedCount)
@@ -790,7 +822,8 @@ func (h *Hub) setupOnTrack(c *Client, channelID int64) {
noPacketTimer := time.AfterFunc(5*time.Second, func() {
slog.Warn("RTP: no packets received after 5s",
"user_id", c.userID,
"channel_id", channelID)
"channel_id", channelID,
"kind", kind)
})
defer noPacketTimer.Stop()
@@ -800,6 +833,7 @@ func (h *Hub) setupOnTrack(c *Client, channelID int64) {
case <-done:
slog.Info("RTP goroutine exiting via done signal",
"user_id", c.userID, "channel_id", channelID,
"kind", kind,
"packets_forwarded", pktCount)
return
default:
@@ -810,6 +844,7 @@ func (h *Hub) setupOnTrack(c *Client, channelID int64) {
slog.Info("RTP read ended",
"user_id", c.userID,
"channel_id", channelID,
"kind", kind,
"packets_forwarded", pktCount,
"err", readErr.Error())
return
@@ -820,6 +855,7 @@ func (h *Hub) setupOnTrack(c *Client, channelID int64) {
slog.Info("RTP write ended",
"user_id", c.userID,
"channel_id", channelID,
"kind", kind,
"packets_forwarded", pktCount,
"err", writeErr.Error())
return
@@ -830,14 +866,21 @@ func (h *Hub) setupOnTrack(c *Client, channelID int64) {
slog.Info("RTP first packet received",
"user_id", c.userID,
"channel_id", channelID,
"kind", kind,
"bytes", n)
} else if pktCount%1000 == 0 {
slog.Info("RTP forwarding",
"user_id", c.userID,
"channel_id", channelID,
"kind", kind,
"packets", pktCount)
}
// Speaker detection only applies to audio tracks.
if kind != "audio" {
continue
}
// Extract audio level directly from raw RTP bytes (avoids full Unmarshal).
level, ok := extractAudioLevel(buf, n)
if !ok {
+100
View File
@@ -2,10 +2,13 @@ package ws_test
import (
"encoding/json"
"fmt"
"testing"
"testing/fstest"
"time"
"github.com/pion/webrtc/v4"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/ws"
@@ -1627,3 +1630,100 @@ func TestVoice_Join_SameChannel_IsIdempotent(t *testing.T) {
}
}
// ─── MaxVideo enforcement ─────────────────────────────────────────────────────
// makeVideoTrack creates a TrackLocalStaticRTP with ID "video-{userID}" for testing.
func makeVideoTrack(t *testing.T, userID int64) *webrtc.TrackLocalStaticRTP {
t.Helper()
local, err := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8},
fmt.Sprintf("video-%d", userID),
fmt.Sprintf("user-%d", userID),
)
if err != nil {
t.Fatalf("NewTrackLocalStaticRTP: %v", err)
}
return local
}
// TestHandleVoiceCamera_MaxVideoEnforced verifies that when the MaxVideo limit
// is reached, a voice_camera enable request is rejected with VIDEO_LIMIT error.
func TestHandleVoiceCamera_MaxVideoEnforced(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-maxvideo")
// Pre-create the room with MaxVideo=2 so handleVoiceJoin reuses it.
hub.GetOrCreateVoiceRoom(chanID, ws.VoiceRoomConfig{
ChannelID: chanID,
MaxUsers: 10,
Quality: "medium",
MixingThreshold: 10,
TopSpeakers: 3,
MaxVideo: 2,
})
// Create 3 users: user1 and user2 will have video tracks, user3 will be rejected.
user1 := seedVoiceOwner(t, database, "maxvid-user1")
user2 := seedVoiceOwner(t, database, "maxvid-user2")
user3 := seedVoiceOwner(t, database, "maxvid-user3")
send1 := make(chan []byte, 32)
c1 := ws.NewTestClientWithUser(hub, user1, chanID, send1)
hub.Register(c1)
send2 := make(chan []byte, 32)
c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2)
hub.Register(c2)
send3 := make(chan []byte, 64)
c3 := ws.NewTestClientWithUser(hub, user3, chanID, send3)
hub.Register(c3)
time.Sleep(20 * time.Millisecond)
// All three join the voice channel.
hub.HandleMessageForTest(c1, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
hub.HandleMessageForTest(c2, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
hub.HandleMessageForTest(c3, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
// Simulate user1 and user2 having video tracks by setting them on the room.
room := hub.GetVoiceRoom(chanID)
if room == nil {
t.Fatal("VoiceRoom should exist after joins")
}
room.SetTrack(user1.ID, "video", nil, makeVideoTrack(t, user1.ID))
room.SetTrack(user2.ID, "video", nil, makeVideoTrack(t, user2.ID))
// Drain all messages from prior operations.
drainChan(send1)
drainChan(send2)
drainChan(send3)
// User3 tries to enable camera — should be rejected with VIDEO_LIMIT.
hub.HandleMessageForTest(c3, voiceCameraMsg(true))
time.Sleep(50 * time.Millisecond)
msgs := drainChan(send3)
foundVideoLimit := false
for _, m := range msgs {
if extractCode(t, m) == "VIDEO_LIMIT" {
foundVideoLimit = true
break
}
}
if !foundVideoLimit {
t.Error("expected VIDEO_LIMIT error when MaxVideo limit is reached")
}
// Verify DB state was NOT updated (camera should still be false).
state, err := database.GetVoiceState(user3.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state != nil && state.Camera {
t.Error("camera should not be enabled after VIDEO_LIMIT rejection")
}
}