package stream import ( "context" "fmt" "io" "log" "os/exec" "sync" "time" "bytes" "tianyan-edge/internal/config" ) type Frame struct { StreamID int DeviceID string URL string JPEG []byte TS float64 } type streamProcess struct { cmd *exec.Cmd fps int } type StreamManager struct { cfg *config.Config frames chan<- Frame processes map[string]*streamProcess // url -> {cmd, fps} mu sync.Mutex updates <-chan *config.Config } func NewStreamManager(cfg *config.Config, frames chan<- Frame, updates <-chan *config.Config) *StreamManager { return &StreamManager{ cfg: cfg, frames: frames, processes: make(map[string]*streamProcess), updates: updates, } } func (sm *StreamManager) Run(ctx context.Context) { // Initial start sm.applyStreams(ctx, sm.cfg.RTSPURLs, sm.cfg.InferFPS) for { select { case <-ctx.Done(): sm.stopAll() return case newCfg := <-sm.updates: log.Printf("stream: applying new config, urls=%d fps=%d", len(newCfg.RTSPURLs), newCfg.InferFPS) sm.applyStreams(ctx, newCfg.RTSPURLs, newCfg.InferFPS) } } } func (sm *StreamManager) applyStreams(ctx context.Context, urls []string, fps int) { sm.mu.Lock() defer sm.mu.Unlock() // Stream enabled check enabled := true if sm.cfg != nil && !sm.cfg.StreamEnabled { enabled = false } // If disabled, stop all streams and return if !enabled { log.Println("stream: disabled by config, stopping all") for url, sp := range sm.processes { log.Printf("stream: stopping %s", url) sm.stopProcess(sp) delete(sm.processes, url) } return } // Identify URLs to remove (present in sm.processes but not in new urls) toRemove := map[string]*streamProcess{} for url, sp := range sm.processes { found := false for _, u := range urls { if u == url { found = true break } } if !found { toRemove[url] = sp } } // Stop removed streams for url, sp := range toRemove { log.Printf("stream: stopping %s", url) sm.stopProcess(sp) delete(sm.processes, url) } // Restart streams with changed fps for url, sp := range sm.processes { if sp.fps != fps { log.Printf("stream: restarting %s (fps %d -> %d)", url, sp.fps, fps) sm.stopProcess(sp) delete(sm.processes, url) } } // Start new / restarted streams for i, url := range urls { if _, exists := sm.processes[url]; !exists { go sm.streamLoop(ctx, i, url, fps) } } } func (sm *StreamManager) streamLoop(ctx context.Context, idx int, url string, fps int) { deviceID := fmt.Sprintf("cam-%03d", idx) for { select { case <-ctx.Done(): return default: } // Use current config fps (may have been updated) currentFps := fps sm.mu.Lock() if sm.cfg != nil && sm.cfg.InferFPS > 0 { currentFps = sm.cfg.InferFPS } sm.mu.Unlock() cmd := exec.CommandContext(ctx, "ffmpeg", "-i", url, "-vf", fmt.Sprintf("fps=%d", currentFps), "-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "5", "-", ) stdout, err := cmd.StdoutPipe() if err != nil { log.Printf("stream[%d] stdout pipe failed: %v", idx, err) time.Sleep(5 * time.Second) continue } sp := &streamProcess{cmd: cmd, fps: currentFps} sm.mu.Lock() sm.processes[url] = sp sm.mu.Unlock() if err := cmd.Start(); err != nil { log.Printf("stream[%d] start failed: %v", idx, err) sm.mu.Lock() delete(sm.processes, url) sm.mu.Unlock() time.Sleep(5 * time.Second) continue } log.Printf("stream[%d] connected fps=%d url=%s", idx, currentFps, url) sm.readFrames(ctx, stdout, idx, deviceID, url) cmd.Wait() // Clean up from map after exit sm.mu.Lock() delete(sm.processes, url) sm.mu.Unlock() select { case <-ctx.Done(): return default: log.Printf("stream[%d] lost, retry 5s", idx) time.Sleep(5 * time.Second) } } } func (sm *StreamManager) readFrames(ctx context.Context, r io.Reader, idx int, deviceID, url string) { soi, eoi := []byte{0xFF, 0xD8}, []byte{0xFF, 0xD9} buf, tmp := make([]byte, 0, 1<<20), make([]byte, 32768) for { select { case <-ctx.Done(): return default: } n, err := r.Read(tmp) if n > 0 { buf = append(buf, tmp[:n]...) for { s := bytes.Index(buf, soi) if s < 0 { buf = buf[:0] break } buf = buf[s:] e := bytes.Index(buf[2:], eoi) if e < 0 { break } end := e + 4 frame := make([]byte, end) copy(frame, buf[:end]) buf = buf[end:] select { case sm.frames <- Frame{StreamID: idx, DeviceID: deviceID, URL: url, JPEG: frame, TS: float64(time.Now().UnixMilli()) / 1000.0}: default: } } } if err != nil { return } } } func (sm *StreamManager) stopProcess(sp *streamProcess) { if sp == nil || sp.cmd == nil || sp.cmd.Process == nil { return } sp.cmd.Process.Kill() sp.cmd.Wait() // Prevent zombie processes } func (sm *StreamManager) stopAll() { sm.mu.Lock() defer sm.mu.Unlock() for url, sp := range sm.processes { log.Printf("stream: stopping all %s", url) sm.stopProcess(sp) delete(sm.processes, url) } }