From 66f35b1eb92f405ac761aff605e040b315de282a Mon Sep 17 00:00:00 2001 From: cclohmar Date: Wed, 5 Aug 2026 09:53:21 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20v0.2.1=20=E2=80=94=20flat=20recording?= =?UTF-8?q?=20dir,=20per-segment=20FFmpeg,=20.part=20atomic=20rename,=20pl?= =?UTF-8?q?ayback=20presets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 17 +++++++ VERSION | 2 +- api.go | 117 ++++++++++++++++++++++++++++++++++++++++++++-- cleaner.go | 73 +++++++++++++++++++---------- public/app.js | 42 +++++++++++++---- public/index.html | 14 +++++- public/style.css | 9 ++++ recorder.go | 112 ++++++++++++++++++++++++++++++++------------ server.go | 4 ++ 9 files changed, 319 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a8752..eaedfde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 6e8bf73..0c62199 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.1 diff --git a/api.go b/api.go index f26a326..c7f1b82 100644 --- a/api.go +++ b/api.go @@ -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}) } diff --git a/cleaner.go b/cleaner.go index f3633dc..2ba27b3 100644 --- a/cleaner.go +++ b/cleaner.go @@ -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. diff --git a/public/app.js b/public/app.js index 8365a3b..99c4f55 100644 --- a/public/app.js +++ b/public/app.js @@ -121,6 +121,7 @@ document.addEventListener('keydown', e => { }); // ── Playback ── +let activePreset = 'today'; function renderPlaybackCameras() { const sel = document.getElementById('pb-camera'); sel.innerHTML = ''; @@ -128,23 +129,48 @@ function renderPlaybackCameras() { sel.innerHTML += ``; }); } -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 = '

No recordings found.

'; return; } + if (clips.length === 0) { grid.innerHTML = '

No recordings found for this selection.

'; return; } grid.innerHTML = clips.map(c => `
-
🎬 ${c.name}
-
${c.time}
+
${c.live ? '🔴 ' : '🎬 '}${c.time}
+
${c.name}
${formatSize(c.size)}
`).join(''); diff --git a/public/index.html b/public/index.html index 4dbb84e..db0ff28 100644 --- a/public/index.html +++ b/public/index.html @@ -38,8 +38,18 @@
- - +
+ + + + +
+ +