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.
|
||||
|
||||
## [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
|
||||
|
|
|
|||
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 (
|
||||
"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})
|
||||
}
|
||||
|
||||
|
|
|
|||
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.
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
112
recorder.go
112
recorder.go
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
Loading…
Reference in a new issue