Implement Tuner v1 — Go backend, React frontend, Docker setup
- Go backend: REST API, M3U parser, FFmpeg/MediaMTX process manager - React/Vite frontend: HLS player, admin panel, channel browser (dark theme) - MediaMTX config for RTMP ingest + HLS output - Multi-stage Dockerfile (Go + Bun + Alpine runtime) - docker-compose.yml for single-container deployment - Sample M3U playlist with test streams Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
115
backend/process/ffmpeg.go
Normal file
115
backend/process/ffmpeg.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tuner/models"
|
||||
)
|
||||
|
||||
// FFmpegManager manages an FFmpeg child process for IPTV relay.
|
||||
type FFmpegManager struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
startTime time.Time
|
||||
lastError string
|
||||
streamURL string
|
||||
}
|
||||
|
||||
// NewFFmpegManager creates a new FFmpeg process manager.
|
||||
func NewFFmpegManager() *FFmpegManager {
|
||||
return &FFmpegManager{}
|
||||
}
|
||||
|
||||
// Start spawns an FFmpeg process to pull from streamURL and push RTMP to MediaMTX.
|
||||
// It kills any existing FFmpeg process first.
|
||||
func (f *FFmpegManager) Start(streamURL string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// Kill existing process if running
|
||||
f.stopLocked()
|
||||
|
||||
f.cmd = exec.Command("ffmpeg",
|
||||
"-re",
|
||||
"-i", streamURL,
|
||||
"-c", "copy",
|
||||
"-f", "flv",
|
||||
"rtmp://localhost:1935/live/stream",
|
||||
)
|
||||
f.streamURL = streamURL
|
||||
f.lastError = ""
|
||||
|
||||
if err := f.cmd.Start(); err != nil {
|
||||
f.lastError = err.Error()
|
||||
return fmt.Errorf("start ffmpeg: %w", err)
|
||||
}
|
||||
|
||||
f.startTime = time.Now()
|
||||
log.Printf("[ffmpeg] started with pid %d, source: %s", f.cmd.Process.Pid, streamURL)
|
||||
|
||||
// Monitor in background
|
||||
go f.monitor()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FFmpegManager) monitor() {
|
||||
err := f.cmd.Wait()
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
f.lastError = err.Error()
|
||||
log.Printf("[ffmpeg] exited: %v", err)
|
||||
} else {
|
||||
log.Println("[ffmpeg] exited (status 0)")
|
||||
}
|
||||
|
||||
f.cmd = nil
|
||||
}
|
||||
|
||||
// Stop kills the FFmpeg process.
|
||||
func (f *FFmpegManager) Stop() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.stopLocked()
|
||||
}
|
||||
|
||||
func (f *FFmpegManager) stopLocked() error {
|
||||
if f.cmd == nil || f.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := f.cmd.Process.Kill(); err != nil {
|
||||
return fmt.Errorf("kill ffmpeg: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the process to fully exit to avoid zombies
|
||||
_ = f.cmd.Wait()
|
||||
f.cmd = nil
|
||||
log.Println("[ffmpeg] killed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the current FFmpeg process status.
|
||||
func (f *FFmpegManager) Status() models.ProcessInfo {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
info := models.ProcessInfo{
|
||||
Error: f.lastError,
|
||||
}
|
||||
|
||||
if f.cmd != nil && f.cmd.Process != nil {
|
||||
info.Running = true
|
||||
info.PID = f.cmd.Process.Pid
|
||||
info.Uptime = time.Since(f.startTime).Truncate(time.Second).String()
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
160
backend/process/mediamtx.go
Normal file
160
backend/process/mediamtx.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"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
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
m.lastError = ""
|
||||
m.stopped = false
|
||||
m.stopCh = make(chan struct{})
|
||||
|
||||
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)
|
||||
|
||||
// Monitor in background and auto-restart on unexpected exit
|
||||
go m.monitor()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user