feat: v0.3.3 — rec_/snap_ prefix, latest.jpg grid, per-minute archival snapshots

This commit is contained in:
Claus Lohmar 2026-08-05 12:37:51 +01:00
parent 06a1252088
commit 69d3a7c47f
6 changed files with 131 additions and 119 deletions

50
api.go
View file

@ -243,6 +243,7 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
Size int64 `json:"size"` Size int64 `json:"size"`
Time string `json:"time"` Time string `json:"time"`
Live bool `json:"live"` Live bool `json:"live"`
Snap string `json:"snap"`
} }
clips := make([]Clip, 0) clips := make([]Clip, 0)
@ -308,18 +309,11 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
} }
name := entry.Name() name := entry.Name()
// Skip in-progress .part files. // Only process rec_*.mp4 files (not snap_*.jpg).
isPart := strings.HasSuffix(strings.ToLower(name), ".part.mp4") if !strings.HasPrefix(name, "rec_") || !strings.HasSuffix(strings.ToLower(name), ".mp4") {
isMP4 := strings.HasSuffix(strings.ToLower(name), ".mp4")
if !isMP4 && !isPart {
continue 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() info, statErr := entry.Info()
if statErr != nil { if statErr != nil {
continue continue
@ -329,21 +323,31 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
Name: name, Name: name,
Path: camDir + "/" + name, Path: camDir + "/" + name,
Size: info.Size(), Size: info.Size(),
Live: isPart,
} }
if parseErr == nil { // Matching snapshot: replace rec_ with snap_, .mp4 with .jpg
clip.Time = fileTime.Format("15:04") snapName := strings.Replace(name, "rec_", "snap_", 1)
// Apply date filter. snapName = strings.Replace(snapName, ".mp4", ".jpg", 1)
if !fromTime.IsZero() && fileTime.Before(fromTime) { 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 continue
} }
if !toTime.IsZero() && fileTime.After(toTime) { if !toTime.IsZero() && t.After(toTime) {
continue 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}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
@ -380,3 +384,17 @@ func itoa(i int) string {
} }
return digits 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"
}

View file

@ -1,7 +1,6 @@
// NextNVR v0.2.1 — Storage retention cleaner // NextNVR v0.3.3 — Storage retention cleaner
// Background ticker that purges recordings older than retention_days. // Purges rec_*.mp4 and snap_*.jpg files older than retention_days.
// Skips .part.mp4 files (in-progress recordings). // Skips latest.jpg (live wall) and rec_*.part.mp4 (in-progress recordings).
// Works with flat directory: /mnt/recordings/{cam-name}/{YYYY-MM-DD-HH-MM}.mp4
package main package main
import ( import (
@ -38,7 +37,6 @@ func (c *Cleaner) Start() {
log.Printf("cleaner: starting — retention=%d days, interval=%v, path=%s", log.Printf("cleaner: starting — retention=%d days, interval=%v, path=%s",
c.config.RetentionDays, interval, c.config.RecordingsPath) c.config.RetentionDays, interval, c.config.RecordingsPath)
// Run immediately on startup.
go c.purge() go c.purge()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
@ -61,60 +59,53 @@ func (c *Cleaner) Stop() {
close(c.stopCh) close(c.stopCh)
} }
// purge walks the recordings directory and deletes files older than retention_days. // purge walks the recordings directory and deletes expired files.
// Skips *.part.mp4 files (currently being recorded or crashed mid-segment).
func (c *Cleaner) purge() { func (c *Cleaner) purge() {
cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour) cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour)
deleted := 0 deleted := 0
var freedBytes int64 var freedBytes int64
err := filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error { filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil || info.IsDir() {
log.Printf("cleaner: walk error for %s: %v", path, err)
return nil // skip unreadable paths
}
if info.IsDir() {
return nil return nil
} }
name := info.Name()
// Skip in-progress recordings. // Skip live wall snapshot and in-progress recordings.
if strings.HasSuffix(strings.ToLower(info.Name()), ".part.mp4") { if name == "latest.jpg" {
// Also clean up orphaned .part files older than 1 hour return nil
// (crashed recordings that were never renamed). }
if strings.HasSuffix(name, ".part.mp4") {
// Clean orphaned .part files older than 1 hour.
if time.Since(info.ModTime()) > 1*time.Hour { if time.Since(info.ModTime()) > 1*time.Hour {
size := info.Size() size := info.Size()
if err := os.Remove(path); err == nil { os.Remove(path)
deleted++ deleted++
freedBytes += size freedBytes += size
log.Printf("cleaner: removed orphaned .part file: %s", filepath.Base(path))
}
} }
return nil return nil
} }
// Only process .mp4 files. // Process rec_*.mp4 and snap_*.jpg files.
if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") { isRec := strings.HasPrefix(name, "rec_") && strings.HasSuffix(name, ".mp4")
isSnap := strings.HasPrefix(name, "snap_") && strings.HasSuffix(name, ".jpg")
if !isRec && !isSnap {
return nil return nil
} }
// Delete if older than retention cutoff.
if info.ModTime().Before(cutoff) { if info.ModTime().Before(cutoff) {
size := info.Size() size := info.Size()
if err := os.Remove(path); err != nil { if err := os.Remove(path); err != nil {
log.Printf("cleaner: failed to remove %s: %v", path, err) log.Printf("cleaner: failed to remove %s: %v", path, err)
return nil } else {
deleted++
freedBytes += size
} }
deleted++
freedBytes += size
} }
return nil return nil
}) })
if err != nil {
log.Printf("cleaner: walk error: %v", err)
}
// Remove empty camera directories.
c.pruneEmptyDirs(c.config.RecordingsPath) c.pruneEmptyDirs(c.config.RecordingsPath)
if deleted > 0 { 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"
}

View file

@ -71,9 +71,8 @@ function renderLiveGrid() {
tile.dataset.camId = cam ? cam.id : ''; tile.dataset.camId = cam ? cam.id : '';
if (cam && cam.enabled) { if (cam && cam.enabled) {
// Static snapshot from disk — updated every 2s by the backend. // Load latest.jpg — the snapshot engine updates this every 2 seconds.
// Cache-bust with timestamp to force refresh. const snapURL = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`;
const snapURL = `/recordings/snapshots/${cam.id}.jpg?t=${Date.now()}`;
tile.innerHTML = ` tile.innerHTML = `
<img src="${snapURL}" class="grid-snap" loading="lazy" <img src="${snapURL}" class="grid-snap" loading="lazy"
onerror="this.parentElement.classList.add('offline')" onerror="this.parentElement.classList.add('offline')"
@ -81,12 +80,9 @@ function renderLiveGrid() {
<span class="tile-status online"></span> <span class="tile-status online"></span>
<span class="tile-label">${cam.name}</span> <span class="tile-label">${cam.name}</span>
`; `;
// Auto-refresh every 3 seconds — no flooding, simple reload.
setInterval(() => { setInterval(() => {
const img = tile.querySelector('img'); const img = tile.querySelector('img');
if (img && !tile.classList.contains('offline')) { if (img) img.src = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`;
img.src = `/recordings/snapshots/${cam.id}.jpg?t=${Date.now()}`;
}
}, 3000); }, 3000);
tile.addEventListener('click', () => openFocus(cam)); tile.addEventListener('click', () => openFocus(cam));
} else { } else {
@ -192,6 +188,8 @@ function renderClips(clips) {
if (clips.length === 0) { grid.innerHTML = '<p style="color:var(--text-muted);grid-column:1/-1">No recordings found for this selection.</p>'; return; } if (clips.length === 0) { grid.innerHTML = '<p style="color:var(--text-muted);grid-column:1/-1">No recordings found for this selection.</p>'; return; }
grid.innerHTML = clips.map(c => ` grid.innerHTML = clips.map(c => `
<div class="clip-card" onclick="playClip('${c.path}')"> <div class="clip-card" onclick="playClip('${c.path}')">
<img src="${c.snap || ''}" class="clip-thumb" loading="lazy"
onerror="this.style.display='none'" alt="${c.time}">
<div>${c.live ? '🔴 ' : '🎬 '}${c.time}</div> <div>${c.live ? '🔴 ' : '🎬 '}${c.time}</div>
<div class="clip-time">${c.name}</div> <div class="clip-time">${c.name}</div>
<div class="clip-size">${formatSize(c.size)}</div> <div class="clip-size">${formatSize(c.size)}</div>

View file

@ -144,6 +144,10 @@ body {
.clip-card:hover { border-color: var(--accent); } .clip-card:hover { border-color: var(--accent); }
.clip-card .clip-time { font-size: 12px; color: var(--text-muted); } .clip-card .clip-time { font-size: 12px; color: var(--text-muted); }
.clip-card .clip-size { font-size: 11px; 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 { .clip-player {
position: fixed; inset: 0; z-index: 200; background: #000; position: fixed; inset: 0; z-index: 200; background: #000;
display: flex; align-items: center; justify-content: center; flex-direction: column; display: flex; align-items: center; justify-content: center; flex-direction: column;

View file

@ -1,8 +1,6 @@
// NextNVR v0.2.1 — FFmpeg stream recorder // NextNVR v0.3.3 — FFmpeg stream recorder
// Launches per-segment FFmpeg processes with stream-copy mode. // Records 5-minute segments with rec_ prefix and second-level timestamps.
// Each recording runs for exactly 5 minutes (-t 300). // Output: /mnt/recordings/{cam-name}/rec_{YYYY-MM-DD-HH-MM-SS}.mp4
// In-progress files use .part.mp4 suffix; atomically renamed on completion.
// This prevents partial/corrupt files and provides crash recovery.
package main package main
import ( import (
@ -118,14 +116,11 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
default: default:
} }
// Generate filename with start-of-segment timestamp. // Generate filename: rec_{YYYY-MM-DD-HH-MM-SS}.mp4
// All timestamps are in local time (BST) — no UTC conversion.
now := time.Now() now := time.Now()
// Round down to the nearest 5-minute boundary for clean naming. timeStr := now.Format("2006-01-02-15-04-05")
rounded := now.Truncate(5 * time.Minute) partFile := filepath.Join(outDir, "rec_"+timeStr+".part.mp4")
timeStr := rounded.Format("2006-01-02-15-04") finalFile := filepath.Join(outDir, "rec_"+timeStr+".mp4")
partFile := filepath.Join(outDir, timeStr+".part.mp4")
finalFile := filepath.Join(outDir, timeStr+".mp4")
// Build FFmpeg command with hard 5-minute timeout. // Build FFmpeg command with hard 5-minute timeout.
cmd := exec.Command("ffmpeg", cmd := exec.Command("ffmpeg",

View file

@ -1,7 +1,6 @@
// NextNVR v0.3.2 — Snapshot engine // NextNVR v0.3.3 — Snapshot engine
// Grabs a single JPEG frame from each camera every 2 seconds. // Writes latest.jpg every 2s (live wall) and snap_{timestamp}.jpg every 60s (archive/thumbnails).
// Writes to /mnt/recordings/snapshots/{cam_id}.jpg via atomic rename. // All files go into the per-camera directory: /mnt/recordings/{cam-name}/
// The web UI loads these static images — no go2rtc polling, no CORS, no flooding.
package main package main
import ( import (
@ -16,9 +15,9 @@ import (
// SnapshotEngine manages per-camera snapshot goroutines. // SnapshotEngine manages per-camera snapshot goroutines.
type SnapshotEngine struct { type SnapshotEngine struct {
mu sync.Mutex mu sync.Mutex
stopChs map[string]chan struct{} stopChs map[string]chan struct{}
config StorageConfig config StorageConfig
} }
// NewSnapshotEngine creates a new snapshot engine. // NewSnapshotEngine creates a new snapshot engine.
@ -31,12 +30,6 @@ func NewSnapshotEngine(cfg StorageConfig) *SnapshotEngine {
// StartAll launches a snapshot goroutine for each enabled camera. // StartAll launches a snapshot goroutine for each enabled camera.
func (se *SnapshotEngine) StartAll(cameras []CameraConfig) { 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 { for _, cam := range cameras {
if !cam.Enabled { if !cam.Enabled {
continue continue
@ -45,9 +38,9 @@ func (se *SnapshotEngine) StartAll(cameras []CameraConfig) {
se.mu.Lock() se.mu.Lock()
se.stopChs[cam.ID] = stopCh se.stopChs[cam.ID] = stopCh
se.mu.Unlock() 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. // StopAll terminates all snapshot goroutines.
@ -61,53 +54,80 @@ func (se *SnapshotEngine) StopAll() {
se.stopChs = make(map[string]chan struct{}) se.stopChs = make(map[string]chan struct{})
} }
// snapshotLoop grabs a JPEG frame every 2 seconds and writes it atomically. // snapshotLoop grabs a JPEG frame every 2 seconds and writes latest.jpg.
func (se *SnapshotEngine) snapshotLoop(cam CameraConfig, outDir string, stopCh chan struct{}) { // Every 60 seconds, copies latest.jpg to snap_{timestamp}.jpg for archival.
func (se *SnapshotEngine) snapshotLoop(cam CameraConfig, stopCh chan struct{}) {
rtspURL := cam.RTSPSub rtspURL := cam.RTSPSub
if rtspURL == "" { if rtspURL == "" {
rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/102", rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/102",
cam.Username, cam.Password, cam.IP) cam.Username, cam.Password, cam.IP)
} }
outFile := filepath.Join(outDir, cam.ID+".jpg") camDirName := cam.Name
tmpFile := outFile + ".tmp" if camDirName == "" {
interval := 2 * time.Second 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. log.Printf("snapshot: %s → %s (live every 2s, archive every 60s)", cam.ID, outDir)
se.grabFrame(rtspURL, tmpFile, outFile, cam.ID)
ticker := time.NewTicker(interval) // Grab first frame immediately.
defer ticker.Stop() se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive)
fastTicker := time.NewTicker(2 * time.Second)
defer fastTicker.Stop()
for { for {
select { select {
case <-stopCh: case <-stopCh:
return return
case <-ticker.C: case <-fastTicker.C:
se.grabFrame(rtspURL, tmpFile, outFile, cam.ID) se.grabFrame(rtspURL, tmpFile, latestFile, outDir, cam.ID, &lastArchive)
} }
} }
} }
// grabFrame launches ffmpeg to capture a single JPEG frame. // grabFrame launches ffmpeg to capture a JPEG. Updates latest.jpg every call,
func (se *SnapshotEngine) grabFrame(rtspURL, tmpFile, outFile, camID string) { // 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", cmd := exec.Command("ffmpeg",
"-hide_banner", "-loglevel", "error", "-hide_banner", "-loglevel", "error",
"-rtsp_transport", "tcp", "-rtsp_transport", "tcp",
"-i", rtspURL, "-i", rtspURL,
"-vframes", "1", // exactly one frame "-vframes", "1",
"-q:v", "5", // good JPEG quality (2-31, lower=better) "-q:v", "5",
"-f", "image2", "-f", "image2",
"-y", // overwrite "-y", tmpFile,
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. // Archive: once per minute, copy latest.jpg to snap_{timestamp}.jpg
if _, err := os.Stat(tmpFile); err == nil { now := time.Now()
os.Rename(tmpFile, outFile) 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)
}