package cmd import ( "context" "fmt" "io" "net/http" "strings" "sync" "time" "github.com/pion/webrtc/v4" "golang.org/x/sync/errgroup" "stream.place/streamplace/pkg/log" ) func WHEP(ctx context.Context, count int, duration time.Duration, endpoint string) error { if duration > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, duration) defer cancel() } w := &WHEPClient{ Endpoint: endpoint, Count: count, } return w.WHEP(ctx) } type WHEPClient struct { StreamKey string File string Endpoint string Count int FreezeAfter time.Duration Stats []map[string]*TrackStats } type WHEPConnection struct { peerConnection *webrtc.PeerConnection audioTrack *webrtc.TrackLocalStaticSample videoTrack *webrtc.TrackLocalStaticSample did string Done func() <-chan struct{} } type TrackStats struct { Total int lastTotal int lastUpdate time.Time mu sync.Mutex } func (w *WHEPClient) StartWHEPConnection(ctx context.Context, stats map[string]*TrackStats) (*WHEPConnection, error) { // Prepare the configuration config := webrtc.Configuration{} // Create a new RTCPeerConnection peerConnection, err := webrtc.NewPeerConnection(config) if err != nil { return nil, err } // Create a ticker to print combined bitrate every 5 seconds ticker := time.NewTicker(5 * time.Second) // Start a goroutine to print combined bitrate go func() { for { select { case <-ticker.C: currentTime := time.Now() // Lock both stats to get a consistent snapshot for _, s := range stats { s.mu.Lock() } videoStats := stats["video"] audioStats := stats["audio"] videoElapsed := currentTime.Sub(videoStats.lastUpdate).Seconds() audioElapsed := currentTime.Sub(audioStats.lastUpdate).Seconds() videoBytes := videoStats.Total - videoStats.lastTotal audioBytes := audioStats.Total - audioStats.lastTotal videoBitrate := float64(videoBytes) * 8 / videoElapsed / 1000 // kbps audioBitrate := float64(audioBytes) * 8 / audioElapsed / 1000 // kbps log.Log(ctx, "bitrate stats", "video", fmt.Sprintf("%.2f kbps (%.2f KB)", videoBitrate, float64(videoBytes)/1000), "audio", fmt.Sprintf("%.2f kbps (%.2f KB)", audioBitrate, float64(audioBytes)/1000), "total", fmt.Sprintf("%.2f kbps", videoBitrate+audioBitrate)) // Update last values videoStats.lastTotal = videoStats.Total videoStats.lastUpdate = currentTime audioStats.lastTotal = audioStats.Total audioStats.lastUpdate = currentTime // Unlock stats for _, s := range stats { s.mu.Unlock() } case <-ctx.Done(): ticker.Stop() return } } }() go func() { ctx, cancel := context.WithCancel(ctx) peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { log.Log(ctx, "track received", "track", track.ID()) // Determine track type trackType := "video" if track.Kind() == webrtc.RTPCodecTypeAudio { trackType = "audio" } trackStat := stats[trackType] for { if ctx.Err() != nil { return } rtp, _, err := track.ReadRTP() if err != nil { log.Log(ctx, "error reading RTP", "error", err) cancel() return } trackStat.mu.Lock() trackStat.Total += len(rtp.Payload) trackStat.mu.Unlock() } }) peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) { log.Log(ctx, "WHEP connection State has changed", "state", connectionState.String()) for _, state := range failureStates { if connectionState == state { log.Log(ctx, "connection failed, cancelling") cancel() } } }) <-ctx.Done() peerConnection.Close() }() peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) { log.Log(ctx, "ICE candidate", "candidate", candidate) }) if _, err := peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{ Direction: webrtc.RTPTransceiverDirectionRecvonly, }); err != nil { return nil, fmt.Errorf("failed to add video transceiver: %w", err) } if _, err := peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ Direction: webrtc.RTPTransceiverDirectionRecvonly, }); err != nil { return nil, fmt.Errorf("failed to add audio transceiver: %w", err) } // Create an offer offer, err := peerConnection.CreateOffer(nil) if err != nil { return nil, err } // Set the generated offer as our LocalDescription err = peerConnection.SetLocalDescription(offer) if err != nil { return nil, err } // Wait for ICE gathering to complete // gatherComplete := webrtc.GatheringCompletePromise(peerConnection) // <-gatherComplete // Create HTTP client and prepare the request client := &http.Client{} // Send the WHIP request to the server req, err := http.NewRequest("POST", w.Endpoint, strings.NewReader(offer.SDP)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/sdp") // Execute the request resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // WHEP proper answers 201 Created (the /api/playback/.../webrtc route); // the XRPC method place.stream.playback.whep goes through the generated // handler, which can only write 200. Either carries the SDP answer. if resp.StatusCode != 200 && resp.StatusCode != 201 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("status code: %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } // Read and process the answer answerBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, err } // Parse the SDP answer var answer webrtc.SessionDescription answer.Type = webrtc.SDPTypeAnswer answer.SDP = string(answerBytes) // Apply the answer as remote description err = peerConnection.SetRemoteDescription(answer) if err != nil { return nil, err } gatherComplete := webrtc.GatheringCompletePromise(peerConnection) <-gatherComplete conn := &WHEPConnection{ peerConnection: peerConnection, Done: ctx.Done, } return conn, nil } func (w *WHEPClient) WHEP(ctx context.Context) error { w.Stats = []map[string]*TrackStats{} ctx, cancel := context.WithCancel(ctx) defer cancel() conns := make([]*WHEPConnection, w.Count) g := &errgroup.Group{} for i := 0; i < w.Count; i++ { stats := map[string]*TrackStats{ "video": {lastUpdate: time.Now()}, "audio": {lastUpdate: time.Now()}, } w.Stats = append(w.Stats, stats) g.Go(func() error { conn, err := w.StartWHEPConnection(ctx, stats) if err != nil { return err } conns[i] = conn <-conn.Done() return nil }) } err := g.Wait() if err != nil { return err } return nil }