chore: v0.2.1 — flat recording dir, per-segment FFmpeg, .part atomic rename, playback presets
This commit is contained in:
parent
170d6fb234
commit
66f35b1eb9
9 changed files with 319 additions and 71 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -2,6 +2,23 @@
|
||||||
|
|
||||||
All notable changes to NextNVR will be documented in this file.
|
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
|
## [0.2.0] — 2026-08-05
|
||||||
### Added
|
### Added
|
||||||
- FFmpeg stream recorder with zero-transcoding (`-c copy`) stream-copy mode
|
- FFmpeg stream recorder with zero-transcoding (`-c copy`) stream-copy mode
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.1.0
|
0.2.1
|
||||||
|
|
|
||||||
117
api.go
117
api.go
|
|
@ -5,7 +5,9 @@ package main
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleCameras returns the list of all cameras with runtime status.
|
// 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.
|
// 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) {
|
func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
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")
|
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 {
|
type Clip struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
|
Live bool `json:"live"`
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = camID
|
|
||||||
_ = date
|
|
||||||
clips := make([]Clip, 0)
|
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})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
73
cleaner.go
73
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.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -12,9 +14,9 @@ import (
|
||||||
|
|
||||||
// Cleaner manages the periodic purge of old recordings.
|
// Cleaner manages the periodic purge of old recordings.
|
||||||
type Cleaner struct {
|
type Cleaner struct {
|
||||||
config StorageConfig
|
config StorageConfig
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCleaner creates a new retention cleaner.
|
// 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.
|
// 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() {
|
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
|
||||||
|
|
@ -67,25 +70,43 @@ func (c *Cleaner) purge() {
|
||||||
|
|
||||||
err := filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error {
|
err := filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
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() {
|
if info.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") {
|
|
||||||
return nil
|
// Skip in-progress recordings.
|
||||||
}
|
if strings.HasSuffix(strings.ToLower(info.Name()), ".part.mp4") {
|
||||||
if info.ModTime().After(cutoff) {
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
size := info.Size()
|
// Only process .mp4 files.
|
||||||
if err := os.Remove(path); err != nil {
|
if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") {
|
||||||
log.Printf("cleaner: failed to remove %s: %v", path, err)
|
|
||||||
return nil
|
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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -93,7 +114,7 @@ func (c *Cleaner) purge() {
|
||||||
log.Printf("cleaner: walk error: %v", err)
|
log.Printf("cleaner: walk error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune empty directories.
|
// Remove empty camera directories.
|
||||||
c.pruneEmptyDirs(c.config.RecordingsPath)
|
c.pruneEmptyDirs(c.config.RecordingsPath)
|
||||||
|
|
||||||
if deleted > 0 {
|
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) {
|
func (c *Cleaner) pruneEmptyDirs(root string) {
|
||||||
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
entries, err := os.ReadDir(root)
|
||||||
if err != nil || !info.IsDir() || path == root {
|
if err != nil {
|
||||||
return nil
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
entries, _ := os.ReadDir(path)
|
dir := filepath.Join(root, entry.Name())
|
||||||
if len(entries) == 0 {
|
contents, _ := os.ReadDir(dir)
|
||||||
os.Remove(path)
|
if len(contents) == 0 {
|
||||||
|
os.Remove(dir)
|
||||||
}
|
}
|
||||||
return nil
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatBytes returns a human-readable byte count.
|
// formatBytes returns a human-readable byte count.
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ document.addEventListener('keydown', e => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Playback ──
|
// ── Playback ──
|
||||||
|
let activePreset = 'today';
|
||||||
function renderPlaybackCameras() {
|
function renderPlaybackCameras() {
|
||||||
const sel = document.getElementById('pb-camera');
|
const sel = document.getElementById('pb-camera');
|
||||||
sel.innerHTML = '<option value="">— Select Camera —</option>';
|
sel.innerHTML = '<option value="">— Select Camera —</option>';
|
||||||
|
|
@ -128,23 +129,48 @@ function renderPlaybackCameras() {
|
||||||
sel.innerHTML += `<option value="${c.id}">${c.name}</option>`;
|
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 cam = document.getElementById('pb-camera').value;
|
||||||
const date = document.getElementById('pb-date').value;
|
if (!cam) return;
|
||||||
if (!cam || !date) 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 {
|
try {
|
||||||
const r = await fetch(API + '/recordings?cam=' + cam + '&date=' + date);
|
const r = await fetch(url);
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
renderClips(j.data || []);
|
renderClips(j.data || []);
|
||||||
} catch(e) { renderClips([]); }
|
} catch(e) { renderClips([]); }
|
||||||
});
|
}
|
||||||
function renderClips(clips) {
|
function renderClips(clips) {
|
||||||
const grid = document.getElementById('pb-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 => `
|
grid.innerHTML = clips.map(c => `
|
||||||
<div class="clip-card" onclick="playClip('${c.path}')">
|
<div class="clip-card" onclick="playClip('${c.path}')">
|
||||||
<div>🎬 ${c.name}</div>
|
<div>${c.live ? '🔴 ' : '🎬 '}${c.time}</div>
|
||||||
<div class="clip-time">${c.time}</div>
|
<div class="clip-time">${c.name}</div>
|
||||||
<div class="clip-size">${formatSize(c.size)}</div>
|
<div class="clip-size">${formatSize(c.size)}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,18 @@
|
||||||
<section id="tab-playback" class="tab-panel">
|
<section id="tab-playback" class="tab-panel">
|
||||||
<div class="playback-controls">
|
<div class="playback-controls">
|
||||||
<select id="pb-camera"><option value="">— Select Camera —</option></select>
|
<select id="pb-camera"><option value="">— Select Camera —</option></select>
|
||||||
<input type="date" id="pb-date">
|
<div class="preset-group">
|
||||||
<button id="pb-load">Load Clips</button>
|
<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>
|
||||||
<div id="pb-clips" class="clip-grid"></div>
|
<div id="pb-clips" class="clip-grid"></div>
|
||||||
<div id="pb-player" class="clip-player hidden">
|
<div id="pb-player" class="clip-player hidden">
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,15 @@ body {
|
||||||
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
|
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
|
||||||
color: var(--text); border-radius: var(--radius); font-size: 14px;
|
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-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
|
||||||
.clip-card {
|
.clip-card {
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
|
|
||||||
112
recorder.go
112
recorder.go
|
|
@ -1,5 +1,8 @@
|
||||||
// NextNVR v0.2.0 — FFmpeg stream recorder
|
// NextNVR v0.2.1 — FFmpeg stream recorder
|
||||||
// Launches per-camera FFmpeg processes with stream-copy mode and auto-reconnect.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -8,6 +11,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -62,10 +66,20 @@ func (rm *RecorderManager) StopAll() {
|
||||||
log.Println("recorder: all processes stopped")
|
log.Println("recorder: all processes stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRecorder launches an FFmpeg process for a single camera.
|
// startRecorder runs the per-segment recording loop for a single camera.
|
||||||
// Uses stream-copy mode (-c copy) for zero transcoding overhead.
|
//
|
||||||
// Segments output into 5-minute MP4 files.
|
// Architecture:
|
||||||
// Auto-reconnects on stream failure with a 5-second delay.
|
// 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) {
|
func (rm *RecorderManager) startRecorder(cam CameraConfig) {
|
||||||
rtspURL := cam.RTSPMain
|
rtspURL := cam.RTSPMain
|
||||||
if rtspURL == "" {
|
if rtspURL == "" {
|
||||||
|
|
@ -83,41 +97,51 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
|
||||||
rm.processes[cam.ID] = proc
|
rm.processes[cam.ID] = proc
|
||||||
rm.mu.Unlock()
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-proc.done:
|
case <-proc.done:
|
||||||
|
log.Printf("recorder: %s — shutdown signal received", cam.ID)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory: /mnt/recordings/{cam_id}/{YYYY-MM-DD}/
|
// Generate filename with start-of-segment timestamp.
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
outDir := filepath.Join(rm.config.RecordingsPath, cam.ID, now.Format("2006-01-02"))
|
// Round down to the nearest 5-minute boundary for clean naming.
|
||||||
if err := os.MkdirAll(outDir, 0755); err != nil {
|
rounded := now.Truncate(5 * time.Minute)
|
||||||
log.Printf("recorder: %s — mkdir failed: %v", cam.ID, err)
|
timeStr := rounded.Format("2006-01-02-15-04")
|
||||||
time.Sleep(5 * time.Second)
|
partFile := filepath.Join(outDir, timeStr+".part.mp4")
|
||||||
continue
|
finalFile := filepath.Join(outDir, timeStr+".mp4")
|
||||||
}
|
|
||||||
|
|
||||||
// Output filename: {HH-MM-SS}.mp4
|
|
||||||
outFile := filepath.Join(outDir, now.Format("15-04-05")+".mp4")
|
|
||||||
|
|
||||||
|
// Build FFmpeg command with hard 5-minute timeout.
|
||||||
cmd := exec.Command("ffmpeg",
|
cmd := exec.Command("ffmpeg",
|
||||||
"-hide_banner", "-loglevel", "error",
|
"-hide_banner", "-loglevel", "error",
|
||||||
"-rtsp_transport", "tcp", // TCP is more reliable than UDP for RTSP
|
"-rtsp_transport", "tcp",
|
||||||
"-i", rtspURL,
|
"-i", rtspURL,
|
||||||
"-c", "copy", // stream-copy: zero transcoding
|
"-c", "copy", // stream-copy: zero transcoding, near-zero CPU
|
||||||
"-f", "segment",
|
"-t", "300", // hard stop after 300 seconds (5 minutes)
|
||||||
"-segment_time", "300", // 5-minute segments
|
"-y", // overwrite output without asking
|
||||||
"-segment_format", "mp4",
|
partFile,
|
||||||
"-reset_timestamps", "1",
|
|
||||||
"-strftime", "1",
|
|
||||||
outFile,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd.Stdout = os.Stdout
|
// Silence FFmpeg by default; uncomment for debugging.
|
||||||
cmd.Stderr = os.Stderr
|
// cmd.Stdout = os.Stdout
|
||||||
|
// cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
log.Printf("recorder: %s — FFmpeg start failed: %v", cam.ID, err)
|
log.Printf("recorder: %s — FFmpeg start failed: %v", cam.ID, err)
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
|
|
@ -125,17 +149,43 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
|
||||||
}
|
}
|
||||||
|
|
||||||
proc.cmd = cmd
|
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()
|
err := cmd.Wait()
|
||||||
proc.cmd = nil
|
proc.cmd = nil
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("recorder: %s — FFmpeg exited: %v — reconnecting in 5s", cam.ID, err)
|
// FFmpeg exited with error or was killed.
|
||||||
} else {
|
// .part file remains — will be cleaned up later.
|
||||||
log.Printf("recorder: %s — FFmpeg exited cleanly — reconnecting in 5s", cam.ID)
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,10 @@ func (s *Server) registerRoutes() {
|
||||||
s.mux.HandleFunc("/api/status", s.handleStatus)
|
s.mux.HandleFunc("/api/status", s.handleStatus)
|
||||||
s.mux.HandleFunc("/api/recordings", s.handleRecordings)
|
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.
|
// Static file server for embedded SPA.
|
||||||
publicFS, _ := fs.Sub(publicFiles, "public")
|
publicFS, _ := fs.Sub(publicFiles, "public")
|
||||||
s.mux.Handle("/", http.FileServer(http.FS(publicFS)))
|
s.mux.Handle("/", http.FileServer(http.FS(publicFS)))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue