feat: v0.3.3 — rec_/snap_ prefix, latest.jpg grid, per-minute archival snapshots
This commit is contained in:
parent
06a1252088
commit
69d3a7c47f
6 changed files with 131 additions and 119 deletions
50
api.go
50
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"
|
||||
}
|
||||
|
|
|
|||
71
cleaner.go
71
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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
19
recorder.go
19
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",
|
||||
|
|
|
|||
94
snapshot.go
94
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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue