// NextNVR — MIT License // Copyright (c) 2026 NextNVR Contributors // SPDX-License-Identifier: MIT // Fetches JPEG frames from go2rtc's already-decoded stream buffer. // go2rtc maintains persistent RTSP connections — frames are instant. // Writes latest.jpg every 1s (live wall) and snap_*.jpg every 60s (archive). package main import ( "fmt" "io" "log" "net/http" "os" "path/filepath" "sync" "time" ) var httpClient = &http.Client{Timeout: 5 * time.Second} // SnapshotEngine manages per-camera snapshot goroutines. type SnapshotEngine struct { mu sync.Mutex stopChs map[string]chan struct{} config StorageConfig } func NewSnapshotEngine(cfg StorageConfig) *SnapshotEngine { return &SnapshotEngine{ stopChs: make(map[string]chan struct{}), config: cfg, } } func (se *SnapshotEngine) StartAll(cameras []CameraConfig) { for _, cam := range cameras { if !cam.Enabled { continue } stopCh := make(chan struct{}) se.mu.Lock() se.stopChs[cam.ID] = stopCh se.mu.Unlock() go se.snapshotLoop(cam, stopCh) } log.Printf("snapshot: %d cameras started (via go2rtc)", len(se.stopChs)) } func (se *SnapshotEngine) StopAll() { se.mu.Lock() defer se.mu.Unlock() for id, ch := range se.stopChs { close(ch) log.Printf("snapshot: stopped camera %s", id) } se.stopChs = make(map[string]chan struct{}) } func (se *SnapshotEngine) snapshotLoop(cam CameraConfig, stopCh chan struct{}) { camDirName := cam.Name if camDirName == "" { camDirName = cam.ID } outDir := filepath.Join(se.config.RecordingsPath, camDirName) os.MkdirAll(outDir, 0755) latestFile := filepath.Join(outDir, "latest.jpg") tmpFile := latestFile + ".tmp" frameURL := fmt.Sprintf("http://127.0.0.1:1984/api/frame.jpeg?src=%s_sub", cam.ID) lastArchive := time.Time{} log.Printf("snapshot: %s → %s", cam.ID, outDir) // Grab first frame immediately. se.grab(frameURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive) ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-stopCh: return case <-ticker.C: se.grab(frameURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive) } } } func (se *SnapshotEngine) grab(url, tmpFile, latestFile, outDir, camID string, lastArchive *time.Time) { resp, err := httpClient.Get(url) if err != nil { return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return } data, err := io.ReadAll(resp.Body) if err != nil || len(data) < 100 { return } // Atomic write. os.WriteFile(tmpFile, data, 0644) os.Rename(tmpFile, latestFile) // Archive every 60s. now := time.Now() if now.Sub(*lastArchive) >= 60*time.Second { archiveFile := filepath.Join(outDir, "snap_"+now.Format("2006-01-02-15-04-05")+".jpg") os.WriteFile(archiveFile, data, 0644) *lastArchive = now } }