Files
tuner/backend/process/mediamtx.go
Scott Register 88a189de34 Tuner v1: HLS pipeline with playback/ingest modes
FFmpeg -> MediaMTX -> HLS relay with runtime-configurable modes:
- PLAYBACK_MODE: standard (mpegts), fmp4 (HEVC), llhls
- INGEST_MODE: rtmp, rtsp
- INGEST_AUDIO_MODE: aac, copy
- Hourly playlist auto-refresh from upstream URL
- Fullscreen video UI with collapsible sidebar, upstream-down banner
- Same-origin HLS proxy for HTTPS deployments

Tagged as the last HLS-based version before the mpegts.js (v2) rework.
2026-06-12 19:17:15 -07:00

190 lines
3.9 KiB
Go

package process
import (
"bufio"
"fmt"
"io"
"log"
"os"
"os/exec"
"sync"
"time"
"tuner/models"
)
// MediaMTXManager manages the MediaMTX child process.
type MediaMTXManager struct {
mu sync.Mutex
cmd *exec.Cmd
binaryPath string
configPath string
extraEnv []string
startTime time.Time
lastError string
stopCh chan struct{}
stopped bool
}
// NewMediaMTXManager creates a new manager for the MediaMTX process.
func NewMediaMTXManager(binaryPath, configPath string) *MediaMTXManager {
return &MediaMTXManager{
binaryPath: binaryPath,
configPath: configPath,
}
}
// SetEnv sets additional environment variables (e.g. MTX_HLSVARIANT) that are
// passed to MediaMTX. Must be called before Start.
func (m *MediaMTXManager) SetEnv(env []string) {
m.mu.Lock()
defer m.mu.Unlock()
m.extraEnv = env
}
// Start launches the MediaMTX process. It auto-restarts on unexpected exit.
func (m *MediaMTXManager) Start() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.cmd != nil && m.cmd.Process != nil {
return fmt.Errorf("mediamtx already running (pid %d)", m.cmd.Process.Pid)
}
return m.startLocked()
}
func (m *MediaMTXManager) startLocked() error {
_, err := exec.LookPath(m.binaryPath)
if err != nil {
m.lastError = fmt.Sprintf("binary not found: %s", m.binaryPath)
return fmt.Errorf("mediamtx binary not found: %s", m.binaryPath)
}
m.cmd = exec.Command(m.binaryPath, m.configPath)
if len(m.extraEnv) > 0 {
m.cmd.Env = append(os.Environ(), m.extraEnv...)
}
m.lastError = ""
m.stopped = false
m.stopCh = make(chan struct{})
// Capture stdout/stderr for logging
stdout, _ := m.cmd.StdoutPipe()
stderr, _ := m.cmd.StderrPipe()
if err := m.cmd.Start(); err != nil {
m.lastError = err.Error()
return fmt.Errorf("start mediamtx: %w", err)
}
m.startTime = time.Now()
log.Printf("[mediamtx] started with pid %d", m.cmd.Process.Pid)
go m.logOutput("stdout", stdout)
go m.logOutput("stderr", stderr)
// Monitor in background and auto-restart on unexpected exit
go m.monitor()
return nil
}
func (m *MediaMTXManager) logOutput(name string, r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
log.Printf("[mediamtx:%s] %s", name, scanner.Text())
}
}
func (m *MediaMTXManager) monitor() {
err := m.cmd.Wait()
m.mu.Lock()
defer m.mu.Unlock()
// Check if this was a deliberate stop
select {
case <-m.stopCh:
log.Println("[mediamtx] stopped")
return
default:
}
if err != nil {
m.lastError = err.Error()
log.Printf("[mediamtx] exited unexpectedly: %v — restarting in 2s", err)
} else {
log.Println("[mediamtx] exited unexpectedly (status 0) — restarting in 2s")
}
m.cmd = nil
// Auto-restart after a brief delay (without lock held)
go func() {
time.Sleep(2 * time.Second)
m.mu.Lock()
defer m.mu.Unlock()
if m.stopped {
return
}
if m.cmd != nil {
return // already restarted
}
if err := m.startLocked(); err != nil {
log.Printf("[mediamtx] auto-restart failed: %v", err)
}
}()
}
// Stop kills the MediaMTX process.
func (m *MediaMTXManager) Stop() error {
m.mu.Lock()
defer m.mu.Unlock()
m.stopped = true
if m.cmd == nil || m.cmd.Process == nil {
return nil
}
if m.stopCh != nil {
close(m.stopCh)
}
if err := m.cmd.Process.Kill(); err != nil {
return fmt.Errorf("kill mediamtx: %w", err)
}
m.cmd = nil
log.Println("[mediamtx] killed")
return nil
}
// Restart stops and restarts the MediaMTX process.
func (m *MediaMTXManager) Restart() error {
if err := m.Stop(); err != nil {
return err
}
time.Sleep(500 * time.Millisecond)
return m.Start()
}
// Status returns the current process status.
func (m *MediaMTXManager) Status() models.ProcessInfo {
m.mu.Lock()
defer m.mu.Unlock()
info := models.ProcessInfo{
Error: m.lastError,
}
if m.cmd != nil && m.cmd.Process != nil {
info.Running = true
info.PID = m.cmd.Process.Pid
info.Uptime = time.Since(m.startTime).Truncate(time.Second).String()
}
return info
}