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"`
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"
}

View file

@ -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 {
os.Remove(path)
deleted++
freedBytes += size
log.Printf("cleaner: removed orphaned .part file: %s", filepath.Base(path))
}
}
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
}
}
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"
}

View file

@ -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 = `
<img src="${snapURL}" class="grid-snap" loading="lazy"
onerror="this.parentElement.classList.add('offline')"
@ -81,12 +80,9 @@ function renderLiveGrid() {
<span class="tile-status online"></span>
<span class="tile-label">${cam.name}</span>
`;
// 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 = '<p style="color:var(--text-muted);grid-column:1/-1">No recordings found for this selection.</p>'; return; }
grid.innerHTML = clips.map(c => `
<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 class="clip-time">${c.name}</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 .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;

View file

@ -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",

View file

@ -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 (
@ -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)
}