From 69d3a7c47fe1810b556f9158b9331aa266718cad Mon Sep 17 00:00:00 2001 From: cclohmar Date: Wed, 5 Aug 2026 12:37:51 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3.3=20=E2=80=94=20rec=5F/snap=5F=20p?= =?UTF-8?q?refix,=20latest.jpg=20grid,=20per-minute=20archival=20snapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api.go | 50 +++++++++++++++++--------- cleaner.go | 71 +++++++++++++----------------------- public/app.js | 12 +++---- public/style.css | 4 +++ recorder.go | 19 ++++------ snapshot.go | 94 +++++++++++++++++++++++++++++------------------- 6 files changed, 131 insertions(+), 119 deletions(-) diff --git a/api.go b/api.go index 4bd4481..65951cd 100644 --- a/api.go +++ b/api.go @@ -243,6 +243,7 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) { Size int64 `json:"size"` Time string `json:"time"` Live bool `json:"live"` + Snap string `json:"snap"` } clips := make([]Clip, 0) @@ -308,18 +309,11 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) { } name := entry.Name() - // Skip in-progress .part files. - isPart := strings.HasSuffix(strings.ToLower(name), ".part.mp4") - isMP4 := strings.HasSuffix(strings.ToLower(name), ".mp4") - - if !isMP4 && !isPart { + // Only process rec_*.mp4 files (not snap_*.jpg). + if !strings.HasPrefix(name, "rec_") || !strings.HasSuffix(strings.ToLower(name), ".mp4") { continue } - // Extract timestamp from filename: YYYY-MM-DD-HH-MM.mp4 - base := strings.TrimSuffix(strings.TrimSuffix(name, ".mp4"), ".part") - fileTime, parseErr := time.Parse("2006-01-02-15-04", base) - info, statErr := entry.Info() if statErr != nil { continue @@ -329,21 +323,31 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) { Name: name, Path: camDir + "/" + name, Size: info.Size(), - Live: isPart, } - if parseErr == nil { - clip.Time = fileTime.Format("15:04") - // Apply date filter. - if !fromTime.IsZero() && fileTime.Before(fromTime) { + // Matching snapshot: replace rec_ with snap_, .mp4 with .jpg + snapName := strings.Replace(name, "rec_", "snap_", 1) + snapName = strings.Replace(snapName, ".mp4", ".jpg", 1) + snapPath := camDir + "/" + snapName + if _, err := os.Stat(recDir + "/" + snapName); err == nil { + clip.Snap = snapPath + } + + // Extract time from filename: rec_YYYY-MM-DD-HH-MM-SS.mp4 + timeStr := strings.TrimPrefix(name, "rec_") + timeStr = strings.TrimSuffix(timeStr, ".mp4") + timeStr = strings.TrimSuffix(timeStr, ".part") + if t, err := time.Parse("2006-01-02-15-04-05", timeStr); err == nil { + clip.Time = t.Format("15:04") + if !fromTime.IsZero() && t.Before(fromTime) { continue } - if !toTime.IsZero() && fileTime.After(toTime) { + if !toTime.IsZero() && t.After(toTime) { continue } } - clips = append([]Clip{clip}, clips...) // prepend = newest first + clips = append([]Clip{clip}, clips...) // newest first } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips}) @@ -380,3 +384,17 @@ func itoa(i int) string { } return digits } + +// formatBytes returns a human-readable byte count. +func formatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return itoa(int(bytes)) + " B" + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return itoa(int(float64(bytes)/float64(div)*10)/10) + " " + string("KMGTPE"[exp]) + "B" +} diff --git a/cleaner.go b/cleaner.go index 2ba27b3..d65c264 100644 --- a/cleaner.go +++ b/cleaner.go @@ -1,7 +1,6 @@ -// NextNVR v0.2.1 — Storage retention cleaner -// Background ticker that purges recordings older than retention_days. -// Skips .part.mp4 files (in-progress recordings). -// Works with flat directory: /mnt/recordings/{cam-name}/{YYYY-MM-DD-HH-MM}.mp4 +// NextNVR v0.3.3 — Storage retention cleaner +// Purges rec_*.mp4 and snap_*.jpg files older than retention_days. +// Skips latest.jpg (live wall) and rec_*.part.mp4 (in-progress recordings). package main import ( @@ -38,7 +37,6 @@ func (c *Cleaner) Start() { log.Printf("cleaner: starting — retention=%d days, interval=%v, path=%s", c.config.RetentionDays, interval, c.config.RecordingsPath) - // Run immediately on startup. go c.purge() ticker := time.NewTicker(interval) @@ -61,60 +59,53 @@ func (c *Cleaner) Stop() { close(c.stopCh) } -// purge walks the recordings directory and deletes files older than retention_days. -// Skips *.part.mp4 files (currently being recorded or crashed mid-segment). +// purge walks the recordings directory and deletes expired files. func (c *Cleaner) purge() { cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour) deleted := 0 var freedBytes int64 - err := filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - log.Printf("cleaner: walk error for %s: %v", path, err) - return nil // skip unreadable paths - } - if info.IsDir() { + filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { return nil } + name := info.Name() - // Skip in-progress recordings. - if strings.HasSuffix(strings.ToLower(info.Name()), ".part.mp4") { - // Also clean up orphaned .part files older than 1 hour - // (crashed recordings that were never renamed). + // Skip live wall snapshot and in-progress recordings. + if name == "latest.jpg" { + return nil + } + if strings.HasSuffix(name, ".part.mp4") { + // Clean orphaned .part files older than 1 hour. if time.Since(info.ModTime()) > 1*time.Hour { size := info.Size() - if err := os.Remove(path); err == nil { - deleted++ - freedBytes += size - log.Printf("cleaner: removed orphaned .part file: %s", filepath.Base(path)) - } + os.Remove(path) + deleted++ + freedBytes += size } return nil } - // Only process .mp4 files. - if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") { + // Process rec_*.mp4 and snap_*.jpg files. + isRec := strings.HasPrefix(name, "rec_") && strings.HasSuffix(name, ".mp4") + isSnap := strings.HasPrefix(name, "snap_") && strings.HasSuffix(name, ".jpg") + + if !isRec && !isSnap { return nil } - // Delete if older than retention cutoff. if info.ModTime().Before(cutoff) { size := info.Size() if err := os.Remove(path); err != nil { log.Printf("cleaner: failed to remove %s: %v", path, err) - return nil + } else { + deleted++ + freedBytes += size } - deleted++ - freedBytes += size } return nil }) - if err != nil { - log.Printf("cleaner: walk error: %v", err) - } - - // Remove empty camera directories. c.pruneEmptyDirs(c.config.RecordingsPath) if deleted > 0 { @@ -139,17 +130,3 @@ func (c *Cleaner) pruneEmptyDirs(root string) { } } } - -// formatBytes returns a human-readable byte count. -func formatBytes(bytes int64) string { - const unit = 1024 - if bytes < unit { - return itoa(int(bytes)) + " B" - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return itoa(int(float64(bytes)/float64(div)*10)/10) + " " + string("KMGTPE"[exp]) + "B" -} diff --git a/public/app.js b/public/app.js index 16db46a..0005556 100644 --- a/public/app.js +++ b/public/app.js @@ -71,9 +71,8 @@ function renderLiveGrid() { tile.dataset.camId = cam ? cam.id : ''; if (cam && cam.enabled) { - // Static snapshot from disk — updated every 2s by the backend. - // Cache-bust with timestamp to force refresh. - const snapURL = `/recordings/snapshots/${cam.id}.jpg?t=${Date.now()}`; + // Load latest.jpg — the snapshot engine updates this every 2 seconds. + const snapURL = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`; tile.innerHTML = ` ${cam.name} `; - // Auto-refresh every 3 seconds — no flooding, simple reload. setInterval(() => { const img = tile.querySelector('img'); - if (img && !tile.classList.contains('offline')) { - img.src = `/recordings/snapshots/${cam.id}.jpg?t=${Date.now()}`; - } + if (img) img.src = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`; }, 3000); tile.addEventListener('click', () => openFocus(cam)); } else { @@ -192,6 +188,8 @@ function renderClips(clips) { if (clips.length === 0) { grid.innerHTML = '

No recordings found for this selection.

'; return; } grid.innerHTML = clips.map(c => `
+ ${c.time}
${c.live ? '🔴 ' : '🎬 '}${c.time}
${c.name}
${formatSize(c.size)}
diff --git a/public/style.css b/public/style.css index 5f742b2..0f3db16 100644 --- a/public/style.css +++ b/public/style.css @@ -144,6 +144,10 @@ body { .clip-card:hover { border-color: var(--accent); } .clip-card .clip-time { font-size: 12px; color: var(--text-muted); } .clip-card .clip-size { font-size: 11px; color: var(--text-muted); } +.clip-thumb { + width: 100%; height: 100px; object-fit: cover; + border-radius: 4px; margin-bottom: 6px; background: var(--bg); +} .clip-player { position: fixed; inset: 0; z-index: 200; background: #000; display: flex; align-items: center; justify-content: center; flex-direction: column; diff --git a/recorder.go b/recorder.go index f9acdfc..f34cccc 100644 --- a/recorder.go +++ b/recorder.go @@ -1,8 +1,6 @@ -// NextNVR v0.2.1 — FFmpeg stream recorder -// Launches per-segment FFmpeg processes with stream-copy mode. -// Each recording runs for exactly 5 minutes (-t 300). -// In-progress files use .part.mp4 suffix; atomically renamed on completion. -// This prevents partial/corrupt files and provides crash recovery. +// NextNVR v0.3.3 — FFmpeg stream recorder +// Records 5-minute segments with rec_ prefix and second-level timestamps. +// Output: /mnt/recordings/{cam-name}/rec_{YYYY-MM-DD-HH-MM-SS}.mp4 package main import ( @@ -118,14 +116,11 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) { default: } - // Generate filename with start-of-segment timestamp. - // All timestamps are in local time (BST) — no UTC conversion. + // Generate filename: rec_{YYYY-MM-DD-HH-MM-SS}.mp4 now := time.Now() - // Round down to the nearest 5-minute boundary for clean naming. - rounded := now.Truncate(5 * time.Minute) - timeStr := rounded.Format("2006-01-02-15-04") - partFile := filepath.Join(outDir, timeStr+".part.mp4") - finalFile := filepath.Join(outDir, timeStr+".mp4") + timeStr := now.Format("2006-01-02-15-04-05") + partFile := filepath.Join(outDir, "rec_"+timeStr+".part.mp4") + finalFile := filepath.Join(outDir, "rec_"+timeStr+".mp4") // Build FFmpeg command with hard 5-minute timeout. cmd := exec.Command("ffmpeg", diff --git a/snapshot.go b/snapshot.go index 97d5132..eb17dab 100644 --- a/snapshot.go +++ b/snapshot.go @@ -1,7 +1,6 @@ -// NextNVR v0.3.2 — Snapshot engine -// Grabs a single JPEG frame from each camera every 2 seconds. -// Writes to /mnt/recordings/snapshots/{cam_id}.jpg via atomic rename. -// The web UI loads these static images — no go2rtc polling, no CORS, no flooding. +// NextNVR v0.3.3 — Snapshot engine +// Writes latest.jpg every 2s (live wall) and snap_{timestamp}.jpg every 60s (archive/thumbnails). +// All files go into the per-camera directory: /mnt/recordings/{cam-name}/ package main import ( @@ -16,9 +15,9 @@ import ( // SnapshotEngine manages per-camera snapshot goroutines. type SnapshotEngine struct { - mu sync.Mutex - stopChs map[string]chan struct{} - config StorageConfig + mu sync.Mutex + stopChs map[string]chan struct{} + config StorageConfig } // NewSnapshotEngine creates a new snapshot engine. @@ -31,12 +30,6 @@ func NewSnapshotEngine(cfg StorageConfig) *SnapshotEngine { // StartAll launches a snapshot goroutine for each enabled camera. func (se *SnapshotEngine) StartAll(cameras []CameraConfig) { - outDir := filepath.Join(se.config.RecordingsPath, "snapshots") - if err := os.MkdirAll(outDir, 0755); err != nil { - log.Printf("snapshot: mkdir %s failed: %v", outDir, err) - return - } - for _, cam := range cameras { if !cam.Enabled { continue @@ -45,9 +38,9 @@ func (se *SnapshotEngine) StartAll(cameras []CameraConfig) { se.mu.Lock() se.stopChs[cam.ID] = stopCh se.mu.Unlock() - go se.snapshotLoop(cam, outDir, stopCh) + go se.snapshotLoop(cam, stopCh) } - log.Printf("snapshot: %d cameras started → %s", len(se.stopChs), outDir) + log.Printf("snapshot: %d cameras started", len(se.stopChs)) } // StopAll terminates all snapshot goroutines. @@ -61,53 +54,80 @@ func (se *SnapshotEngine) StopAll() { se.stopChs = make(map[string]chan struct{}) } -// snapshotLoop grabs a JPEG frame every 2 seconds and writes it atomically. -func (se *SnapshotEngine) snapshotLoop(cam CameraConfig, outDir string, stopCh chan struct{}) { +// snapshotLoop grabs a JPEG frame every 2 seconds and writes latest.jpg. +// Every 60 seconds, copies latest.jpg to snap_{timestamp}.jpg for archival. +func (se *SnapshotEngine) snapshotLoop(cam CameraConfig, stopCh chan struct{}) { rtspURL := cam.RTSPSub if rtspURL == "" { rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/102", cam.Username, cam.Password, cam.IP) } - outFile := filepath.Join(outDir, cam.ID+".jpg") - tmpFile := outFile + ".tmp" - interval := 2 * time.Second + camDirName := cam.Name + if camDirName == "" { + camDirName = cam.ID + } + outDir := filepath.Join(se.config.RecordingsPath, camDirName) + if err := os.MkdirAll(outDir, 0755); err != nil { + log.Printf("snapshot: %s — mkdir %s failed: %v", cam.ID, outDir, err) + return + } - log.Printf("snapshot: %s → %s (every %v)", cam.ID, outFile, interval) + latestFile := filepath.Join(outDir, "latest.jpg") + tmpFile := latestFile + ".tmp" + lastArchive := time.Time{} // track when we last wrote an archival snapshot - // Immediately grab the first frame. - se.grabFrame(rtspURL, tmpFile, outFile, cam.ID) + log.Printf("snapshot: %s → %s (live every 2s, archive every 60s)", cam.ID, outDir) - ticker := time.NewTicker(interval) - defer ticker.Stop() + // Grab first frame immediately. + se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive) + + fastTicker := time.NewTicker(2 * time.Second) + defer fastTicker.Stop() for { select { case <-stopCh: return - case <-ticker.C: - se.grabFrame(rtspURL, tmpFile, outFile, cam.ID) + case <-fastTicker.C: + se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive) } } } -// grabFrame launches ffmpeg to capture a single JPEG frame. -func (se *SnapshotEngine) grabFrame(rtspURL, tmpFile, outFile, camID string) { +// grabFrame launches ffmpeg to capture a JPEG. Updates latest.jpg every call, +// and copies to snap_*.jpg once per minute. +func (se *SnapshotEngine) grabFrame(rtspURL, tmpFile, latestFile, outDir, camID string, lastArchive *time.Time) { cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-rtsp_transport", "tcp", "-i", rtspURL, - "-vframes", "1", // exactly one frame - "-q:v", "5", // good JPEG quality (2-31, lower=better) + "-vframes", "1", + "-q:v", "5", "-f", "image2", - "-y", // overwrite - tmpFile, + "-y", tmpFile, ) + cmd.Run() - cmd.Run() // ignore errors — camera may be offline + if _, err := os.Stat(tmpFile); err != nil { + return // ffmpeg failed — camera may be offline + } + os.Rename(tmpFile, latestFile) - // Atomic rename: if ffmpeg succeeded, tmpFile exists. - if _, err := os.Stat(tmpFile); err == nil { - os.Rename(tmpFile, outFile) + // Archive: once per minute, copy latest.jpg to snap_{timestamp}.jpg + now := time.Now() + if now.Sub(*lastArchive) >= 60*time.Second { + archiveFile := filepath.Join(outDir, "snap_"+now.Format("2006-01-02-15-04-05")+".jpg") + copyFile(latestFile, archiveFile) + *lastArchive = now } } + +// copyFile copies a file from src to dst. +func copyFile(src, dst string) { + data, err := os.ReadFile(src) + if err != nil { + return + } + os.WriteFile(dst, data, 0644) +}