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 => `