Files
OwnCord/Server/ws/livekit_process.go
T
jevb 80e880d536 feat: add LiveKit infrastructure (Phase 0)
- Replace VoiceConfig STUN/TURN/MediaPort fields with LiveKit
  API key, secret, URL, and binary path
- Add livekit_process.go: companion process manager with
  auto-restart and config generation
- Add livekit.go: token generation, room service client,
  participant management, video track counting
- Add LiveKit server SDK dependency (server-sdk-go/v2)
- Update config tests for new VoiceConfig fields
2026-03-20 05:14:52 +01:00

183 lines
4.0 KiB
Go

// Package ws provides the LiveKit companion process manager.
//
// LiveKitProcess manages the lifecycle of a livekit-server binary running
// alongside chatserver. It auto-generates a minimal livekit.yaml config,
// starts the process, monitors health, and restarts on crash.
package ws
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/owncord/server/config"
)
// LiveKitProcess manages the companion livekit-server binary.
type LiveKitProcess struct {
cfg *config.VoiceConfig
dataDir string
mu sync.Mutex
cmd *exec.Cmd
cancel context.CancelFunc
stopped bool
}
// NewLiveKitProcess creates a new process manager. It does not start the
// process — call Start() to launch the LiveKit server.
func NewLiveKitProcess(cfg *config.VoiceConfig, dataDir string) *LiveKitProcess {
return &LiveKitProcess{
cfg: cfg,
dataDir: dataDir,
}
}
// generateConfig writes a minimal livekit.yaml for the companion process.
func (p *LiveKitProcess) generateConfig() (string, error) {
cfgPath := filepath.Join(p.dataDir, "livekit.yaml")
content := fmt.Sprintf(`# Auto-generated by OwnCord — do not edit manually.
port: 7880
rtc:
port_range_start: 50000
port_range_end: 60000
use_external_ip: true
keys:
%s: %s
logging:
level: info
`, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret)
if err := os.MkdirAll(p.dataDir, 0o755); err != nil {
return "", fmt.Errorf("creating data dir: %w", err)
}
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
return "", fmt.Errorf("writing livekit config: %w", err)
}
return cfgPath, nil
}
// Start launches the livekit-server binary. If LiveKitBinaryPath is empty,
// this is a no-op (assumes LiveKit is managed externally).
func (p *LiveKitProcess) Start() error {
if p.cfg.LiveKitBinaryPath == "" {
slog.Info("livekit: no binary path configured, assuming externally managed")
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
if p.cmd != nil {
return fmt.Errorf("livekit process already running")
}
cfgPath, err := p.generateConfig()
if err != nil {
return fmt.Errorf("generating livekit config: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
go p.runLoop(ctx, cfgPath)
return nil
}
// runLoop starts and restarts the process until stopped or context cancelled.
func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) {
const restartDelay = 3 * time.Second
for {
if ctx.Err() != nil {
return
}
cmd := exec.CommandContext(ctx, p.cfg.LiveKitBinaryPath, "--config", cfgPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
p.mu.Lock()
if p.stopped {
p.mu.Unlock()
return
}
p.cmd = cmd
p.mu.Unlock()
slog.Info("livekit: starting process",
"binary", p.cfg.LiveKitBinaryPath,
"config", cfgPath)
err := cmd.Run()
p.mu.Lock()
p.cmd = nil
stopped := p.stopped
p.mu.Unlock()
if stopped || ctx.Err() != nil {
slog.Info("livekit: process stopped")
return
}
if err != nil {
slog.Error("livekit: process exited unexpectedly",
"error", err,
"restart_delay", restartDelay)
}
select {
case <-time.After(restartDelay):
slog.Info("livekit: restarting process")
case <-ctx.Done():
return
}
}
}
// IsRunning returns true if the companion process is currently running.
func (p *LiveKitProcess) IsRunning() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.cmd != nil && p.cmd.Process != nil
}
// Stop gracefully stops the companion process.
func (p *LiveKitProcess) Stop() {
p.mu.Lock()
p.stopped = true
cancel := p.cancel
cmd := p.cmd
p.mu.Unlock()
if cancel != nil {
cancel()
}
// Wait briefly for the process to exit after context cancellation
if cmd != nil && cmd.Process != nil {
done := make(chan struct{})
go func() {
_ = cmd.Wait()
close(done)
}()
select {
case <-done:
slog.Info("livekit: process exited cleanly")
case <-time.After(5 * time.Second):
slog.Warn("livekit: process did not exit in time, killing")
_ = cmd.Process.Kill()
}
}
}