// NextNVR v0.2.0 — FFmpeg stream recorder // Launches per-camera FFmpeg processes with stream-copy mode and auto-reconnect. package main import ( "fmt" "log" "os" "os/exec" "path/filepath" "sync" "time" ) // RecorderManager manages FFmpeg recording processes for all cameras. type RecorderManager struct { mu sync.Mutex processes map[string]*recorderProcess config StorageConfig } type recorderProcess struct { camera CameraConfig cmd *exec.Cmd done chan struct{} } // NewRecorderManager creates a new recorder manager. func NewRecorderManager(cfg StorageConfig) *RecorderManager { return &RecorderManager{ processes: make(map[string]*recorderProcess), config: cfg, } } // StartAll launches recording goroutines for all enabled cameras. // Cameras are started with a 500ms stagger to avoid I/O and CPU spikes. func (rm *RecorderManager) StartAll(cameras []CameraConfig) { for _, cam := range cameras { if !cam.Enabled || !cam.Record { log.Printf("recorder: skipping camera %s (enabled=%v, record=%v)", cam.ID, cam.Enabled, cam.Record) continue } go rm.startRecorder(cam) time.Sleep(500 * time.Millisecond) // staggered startup } log.Printf("recorder: all cameras launched") } // StopAll terminates all recording processes. func (rm *RecorderManager) StopAll() { rm.mu.Lock() defer rm.mu.Unlock() for id, proc := range rm.processes { log.Printf("recorder: stopping camera %s", id) close(proc.done) if proc.cmd != nil && proc.cmd.Process != nil { proc.cmd.Process.Signal(os.Interrupt) } } log.Println("recorder: all processes stopped") } // startRecorder launches an FFmpeg process for a single camera. // Uses stream-copy mode (-c copy) for zero transcoding overhead. // Segments output into 5-minute MP4 files. // Auto-reconnects on stream failure with a 5-second delay. func (rm *RecorderManager) startRecorder(cam CameraConfig) { rtspURL := cam.RTSPMain if rtspURL == "" { rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/101", cam.Username, cam.Password, cam.IP) } log.Printf("recorder: starting %s → %s", cam.ID, rtspURL) rm.mu.Lock() proc := &recorderProcess{ camera: cam, done: make(chan struct{}), } rm.processes[cam.ID] = proc rm.mu.Unlock() for { select { case <-proc.done: return default: } // Create output directory: /mnt/recordings/{cam_id}/{YYYY-MM-DD}/ now := time.Now() outDir := filepath.Join(rm.config.RecordingsPath, cam.ID, now.Format("2006-01-02")) if err := os.MkdirAll(outDir, 0755); err != nil { log.Printf("recorder: %s — mkdir failed: %v", cam.ID, err) time.Sleep(5 * time.Second) continue } // Output filename: {HH-MM-SS}.mp4 outFile := filepath.Join(outDir, now.Format("15-04-05")+".mp4") cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-rtsp_transport", "tcp", // TCP is more reliable than UDP for RTSP "-i", rtspURL, "-c", "copy", // stream-copy: zero transcoding "-f", "segment", "-segment_time", "300", // 5-minute segments "-segment_format", "mp4", "-reset_timestamps", "1", "-strftime", "1", outFile, ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { log.Printf("recorder: %s — FFmpeg start failed: %v", cam.ID, err) time.Sleep(5 * time.Second) continue } proc.cmd = cmd log.Printf("recorder: %s — FFmpeg running (PID %d)", cam.ID, cmd.Process.Pid) err := cmd.Wait() proc.cmd = nil if err != nil { log.Printf("recorder: %s — FFmpeg exited: %v — reconnecting in 5s", cam.ID, err) } else { log.Printf("recorder: %s — FFmpeg exited cleanly — reconnecting in 5s", cam.ID) } time.Sleep(5 * time.Second) } }