chore: v0.2.1 — flat recording dir, per-segment FFmpeg, .part atomic rename, playback presets

This commit is contained in:
Claus Lohmar 2026-08-05 09:53:21 +01:00
parent 170d6fb234
commit 66f35b1eb9
9 changed files with 319 additions and 71 deletions

View file

@ -2,6 +2,23 @@
All notable changes to NextNVR will be documented in this file.
## [0.2.1] — 2026-08-05
### Changed
- **Flat recording directory**: `/mnt/recordings/{cam-name}/{YYYY-MM-DD-HH-MM}.mp4`
- **Per-segment FFmpeg**: replaced segment muxer with `-t 300` per-segment approach
- **Atomic file naming**: in-progress recordings use `.part.mp4` suffix, renamed on completion
- **Crash recovery**: `.part` files older than 1 hour auto-cleaned by retention loop
- Cleaner skips `.part.mp4` files (active recordings)
- Cleaner prunes empty camera directories after purge
### Added
- Playback presets: Today, Yesterday, Last 7 Days, Custom date range
- `/recordings/` static file server for video playback
- Recordings API filters by preset or custom date range
- 🔴 live badge for in-progress `.part` files in playback UI
### Removed
- Nested date subdirectories (replaced by flat per-camera structure)
- Complex FFmpeg segment muxer flags (`-f segment`, `-segment_time`, `-strftime`)
## [0.2.0] — 2026-08-05
### Added
- FFmpeg stream recorder with zero-transcoding (`-c copy`) stream-copy mode

View file

@ -1 +1 @@
0.1.0
0.2.1

117
api.go
View file

@ -5,7 +5,9 @@ package main
import (
"encoding/json"
"net/http"
"os"
"strings"
"time"
)
// handleCameras returns the list of all cameras with runtime status.
@ -171,7 +173,13 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
}
// handleRecordings returns available recordings for playback.
// GET /api/recordings?cam={id}&date={YYYY-MM-DD}
// GET /api/recordings?cam={id}&preset={today|yesterday|week}&from={date}&to={date}
//
// The recordings directory structure is flat per camera:
//
// /mnt/recordings/{cam-name}/{YYYY-MM-DD-HH-MM}.mp4
//
// In-progress recordings use .part.mp4 suffix and are excluded from results.
func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
@ -179,20 +187,119 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
}
camID := r.URL.Query().Get("cam")
date := r.URL.Query().Get("date")
preset := r.URL.Query().Get("preset")
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
// TODO: M2/M3 — scan filesystem for actual .mp4 files.
type Clip struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Time string `json:"time"`
Live bool `json:"live"`
}
_ = camID
_ = date
clips := make([]Clip, 0)
if camID == "" {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
return
}
// Resolve camera name for directory lookup.
cam, _ := s.findCamera(camID)
camDir := camID
if cam != nil && cam.Name != "" {
camDir = cam.Name
}
// Determine date range from preset or custom range.
var fromTime, toTime time.Time
now := time.Now()
switch preset {
case "today":
fromTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
toTime = now
case "yesterday":
yesterday := now.AddDate(0, 0, -1)
fromTime = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, now.Location())
toTime = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 59, 59, 0, now.Location())
case "week":
fromTime = now.AddDate(0, 0, -7)
toTime = now
default:
// Parse custom date range.
if fromStr != "" {
fromTime, _ = time.Parse("2006-01-02", fromStr)
}
if toStr != "" {
toTime, _ = time.Parse("2006-01-02", toStr)
toTime = toTime.Add(24*time.Hour - time.Second) // end of day
}
if fromStr == "" {
fromTime = now.AddDate(0, 0, -1) // default: last 24h
}
if toStr == "" {
toTime = now
}
}
// Scan the flat camera directory.
recDir := s.appConfig.Storage.RecordingsPath
scanDir := recDir + "/" + camDir
entries, err := os.ReadDir(scanDir)
if err != nil {
// Directory doesn't exist yet — no recordings.
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
return
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
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 {
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
}
clip := Clip{
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) {
continue
}
if !toTime.IsZero() && fileTime.After(toTime) {
continue
}
}
clips = append([]Clip{clip}, clips...) // prepend = newest first
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
}

View file

@ -1,5 +1,7 @@
// NextNVR v0.2.0 — Storage retention cleaner
// 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
package main
import (
@ -12,9 +14,9 @@ import (
// Cleaner manages the periodic purge of old recordings.
type Cleaner struct {
config StorageConfig
stopCh chan struct{}
running bool
config StorageConfig
stopCh chan struct{}
running bool
}
// NewCleaner creates a new retention cleaner.
@ -60,6 +62,7 @@ func (c *Cleaner) Stop() {
}
// purge walks the recordings directory and deletes files older than retention_days.
// Skips *.part.mp4 files (currently being recorded or crashed mid-segment).
func (c *Cleaner) purge() {
cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour)
deleted := 0
@ -67,25 +70,43 @@ func (c *Cleaner) purge() {
err := filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip unreadable files
log.Printf("cleaner: walk error for %s: %v", path, err)
return nil // skip unreadable paths
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") {
return nil
}
if info.ModTime().After(cutoff) {
// 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).
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))
}
}
return nil
}
size := info.Size()
if err := os.Remove(path); err != nil {
log.Printf("cleaner: failed to remove %s: %v", path, err)
// Only process .mp4 files.
if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") {
return nil
}
deleted++
freedBytes += size
// 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
}
deleted++
freedBytes += size
}
return nil
})
@ -93,7 +114,7 @@ func (c *Cleaner) purge() {
log.Printf("cleaner: walk error: %v", err)
}
// Prune empty directories.
// Remove empty camera directories.
c.pruneEmptyDirs(c.config.RecordingsPath)
if deleted > 0 {
@ -101,18 +122,22 @@ func (c *Cleaner) purge() {
}
}
// pruneEmptyDirs removes empty date directories within the recordings tree.
// pruneEmptyDirs removes empty camera directories.
func (c *Cleaner) pruneEmptyDirs(root string) {
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || !info.IsDir() || path == root {
return nil
entries, err := os.ReadDir(root)
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
entries, _ := os.ReadDir(path)
if len(entries) == 0 {
os.Remove(path)
dir := filepath.Join(root, entry.Name())
contents, _ := os.ReadDir(dir)
if len(contents) == 0 {
os.Remove(dir)
}
return nil
})
}
}
// formatBytes returns a human-readable byte count.

View file

@ -121,6 +121,7 @@ document.addEventListener('keydown', e => {
});
// ── Playback ──
let activePreset = 'today';
function renderPlaybackCameras() {
const sel = document.getElementById('pb-camera');
sel.innerHTML = '<option value="">— Select Camera —</option>';
@ -128,23 +129,48 @@ function renderPlaybackCameras() {
sel.innerHTML += `<option value="${c.id}">${c.name}</option>`;
});
}
document.getElementById('pb-load').addEventListener('click', async () => {
// Preset buttons.
document.querySelectorAll('.preset').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activePreset = btn.dataset.preset;
const customRange = document.getElementById('pb-custom-range');
if (activePreset === 'custom') {
customRange.classList.remove('hidden');
} else {
customRange.classList.add('hidden');
loadClips(); // auto-load on preset change
}
});
});
document.getElementById('pb-load').addEventListener('click', loadClips);
async function loadClips() {
const cam = document.getElementById('pb-camera').value;
const date = document.getElementById('pb-date').value;
if (!cam || !date) return;
if (!cam) return;
let url = API + '/recordings?cam=' + cam + '&preset=' + activePreset;
if (activePreset === 'custom') {
const from = document.getElementById('pb-from').value;
const to = document.getElementById('pb-to').value;
if (from) url += '&from=' + from;
if (to) url += '&to=' + to;
}
try {
const r = await fetch(API + '/recordings?cam=' + cam + '&date=' + date);
const r = await fetch(url);
const j = await r.json();
renderClips(j.data || []);
} catch(e) { renderClips([]); }
});
}
function renderClips(clips) {
const grid = document.getElementById('pb-clips');
if (clips.length === 0) { grid.innerHTML = '<p style="color:var(--text-muted)">No recordings found.</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 => `
<div class="clip-card" onclick="playClip('${c.path}')">
<div>🎬 ${c.name}</div>
<div class="clip-time">${c.time}</div>
<div>${c.live ? '🔴 ' : '🎬 '}${c.time}</div>
<div class="clip-time">${c.name}</div>
<div class="clip-size">${formatSize(c.size)}</div>
</div>
`).join('');

View file

@ -38,8 +38,18 @@
<section id="tab-playback" class="tab-panel">
<div class="playback-controls">
<select id="pb-camera"><option value="">— Select Camera —</option></select>
<input type="date" id="pb-date">
<button id="pb-load">Load Clips</button>
<div class="preset-group">
<button class="preset active" data-preset="today">Today</button>
<button class="preset" data-preset="yesterday">Yesterday</button>
<button class="preset" data-preset="week">Last 7 Days</button>
<button class="preset" data-preset="custom">Custom ▾</button>
</div>
<div id="pb-custom-range" class="hidden" style="display:flex;gap:8px;align-items:center">
<input type="date" id="pb-from">
<span></span>
<input type="date" id="pb-to">
</div>
<button id="pb-load" class="btn-primary">Load Clips</button>
</div>
<div id="pb-clips" class="clip-grid"></div>
<div id="pb-player" class="clip-player hidden">

View file

@ -126,6 +126,15 @@ body {
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
color: var(--text); border-radius: var(--radius); font-size: 14px;
}
.preset-group { display: flex; gap: 4px; }
.preset {
background: var(--surface); border: 1px solid var(--border);
color: var(--text-muted); padding: 7px 12px; border-radius: var(--radius);
cursor: pointer; font-size: 13px; transition: all .15s;
}
.preset:hover { color: var(--text); border-color: var(--accent); }
.preset.active { background: var(--accent); color: #fff; border-color: var(--accent); }
#pb-custom-range.hidden { display: none !important; }
.clip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
.clip-card {
background: var(--surface); border: 1px solid var(--border);

View file

@ -1,5 +1,8 @@
// NextNVR v0.2.0 — FFmpeg stream recorder
// Launches per-camera FFmpeg processes with stream-copy mode and auto-reconnect.
// 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.
package main
import (
@ -8,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
@ -62,10 +66,20 @@ func (rm *RecorderManager) StopAll() {
log.Println("recorder: all processes stopped")
}
// startRecorder launches an FFmpeg process for a single camera.
// Uses stream-copy mode (-c copy) for zero transcoding overhead.
// Segments output into 5-minute MP4 files.
// Auto-reconnects on stream failure with a 5-second delay.
// startRecorder runs the per-segment recording loop for a single camera.
//
// Architecture:
// 1. Build RTSP URL from camera config.
// 2. Create output directory: /mnt/recordings/{cam-name}/
// 3. LOOP:
// a. Compute filename: YYYY-MM-DD-HH-MM.part.mp4 (start-of-segment timestamp)
// b. Launch ffmpeg -t 300 -i {rtsp} -c copy → .part.mp4
// c. On success: atomic rename .part.mp4 → .mp4
// d. On failure: .part file stays (picked up by cleaner), wait 5s, retry
//
// Each segment is exactly 5 minutes. Gaps between segments are ~1-2s
// (FFmpeg restart overhead). The -t 300 flag provides a hard timeout
// preventing hung FFmpeg processes.
func (rm *RecorderManager) startRecorder(cam CameraConfig) {
rtspURL := cam.RTSPMain
if rtspURL == "" {
@ -83,41 +97,51 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
rm.processes[cam.ID] = proc
rm.mu.Unlock()
// Use camera name for the directory (human-readable).
// Fall back to cam ID if name is empty.
camDirName := cam.Name
if camDirName == "" {
camDirName = cam.ID
}
outDir := filepath.Join(rm.config.RecordingsPath, camDirName)
if err := os.MkdirAll(outDir, 0755); err != nil {
log.Printf("recorder: %s — mkdir %s failed: %v", cam.ID, outDir, err)
return
}
for {
select {
case <-proc.done:
log.Printf("recorder: %s — shutdown signal received", cam.ID)
return
default:
}
// Create output directory: /mnt/recordings/{cam_id}/{YYYY-MM-DD}/
// Generate filename with start-of-segment timestamp.
now := time.Now()
outDir := filepath.Join(rm.config.RecordingsPath, cam.ID, now.Format("2006-01-02"))
if err := os.MkdirAll(outDir, 0755); err != nil {
log.Printf("recorder: %s — mkdir failed: %v", cam.ID, err)
time.Sleep(5 * time.Second)
continue
}
// Output filename: {HH-MM-SS}.mp4
outFile := filepath.Join(outDir, now.Format("15-04-05")+".mp4")
// 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")
// Build FFmpeg command with hard 5-minute timeout.
cmd := exec.Command("ffmpeg",
"-hide_banner", "-loglevel", "error",
"-rtsp_transport", "tcp", // TCP is more reliable than UDP for RTSP
"-rtsp_transport", "tcp",
"-i", rtspURL,
"-c", "copy", // stream-copy: zero transcoding
"-f", "segment",
"-segment_time", "300", // 5-minute segments
"-segment_format", "mp4",
"-reset_timestamps", "1",
"-strftime", "1",
outFile,
"-c", "copy", // stream-copy: zero transcoding, near-zero CPU
"-t", "300", // hard stop after 300 seconds (5 minutes)
"-y", // overwrite output without asking
partFile,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Silence FFmpeg by default; uncomment for debugging.
// cmd.Stdout = os.Stdout
// cmd.Stderr = os.Stderr
startTime := time.Now()
if err := cmd.Start(); err != nil {
log.Printf("recorder: %s — FFmpeg start failed: %v", cam.ID, err)
time.Sleep(5 * time.Second)
@ -125,17 +149,43 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
}
proc.cmd = cmd
log.Printf("recorder: %s — FFmpeg running (PID %d)", cam.ID, cmd.Process.Pid)
log.Printf("recorder: %s — segment %s (PID %d)", cam.ID, timeStr, cmd.Process.Pid)
err := cmd.Wait()
proc.cmd = nil
elapsed := time.Since(startTime)
if err != nil {
log.Printf("recorder: %s — FFmpeg exited: %v — reconnecting in 5s", cam.ID, err)
} else {
log.Printf("recorder: %s — FFmpeg exited cleanly — reconnecting in 5s", cam.ID)
// FFmpeg exited with error or was killed.
// .part file remains — will be cleaned up later.
if strings.Contains(err.Error(), "signal: killed") || strings.Contains(err.Error(), "signal: interrupt") {
log.Printf("recorder: %s — segment %s killed after %v (shutdown)", cam.ID, timeStr, elapsed.Round(time.Second))
return
}
log.Printf("recorder: %s — segment %s failed after %v: %v — retrying in 5s", cam.ID, timeStr, elapsed.Round(time.Second), err)
// Remove partial file on error so it doesn't accumulate.
os.Remove(partFile)
time.Sleep(5 * time.Second)
continue
}
time.Sleep(5 * time.Second)
// Segment completed successfully — atomic rename.
if err := os.Rename(partFile, finalFile); err != nil {
log.Printf("recorder: %s — rename %s → %s failed: %v", cam.ID, partFile, finalFile, err)
} else {
log.Printf("recorder: %s — segment %s complete (%v, %d bytes)",
cam.ID, timeStr, elapsed.Round(time.Second), fileSize(finalFile))
}
// Immediate loop — next segment starts with current timestamp.
}
}
// fileSize returns the size of a file in bytes, or 0 on error.
func fileSize(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.Size()
}

View file

@ -63,6 +63,10 @@ func (s *Server) registerRoutes() {
s.mux.HandleFunc("/api/status", s.handleStatus)
s.mux.HandleFunc("/api/recordings", s.handleRecordings)
// Static file server for recordings (actual video files on disk).
s.mux.Handle("/recordings/", http.StripPrefix("/recordings/",
http.FileServer(http.Dir(s.config.Storage.RecordingsPath))))
// Static file server for embedded SPA.
publicFS, _ := fs.Sub(publicFiles, "public")
s.mux.Handle("/", http.FileServer(http.FS(publicFS)))