NextNVR/main.go
cclohmar f794c57198 fix: retry recording when storage is slow to mount at boot
A power cut makes the recordings drive's fsck slow, so the mount can
appear tens of seconds after nextnvr starts. The recorders then hit the
unmounted, root-owned mountpoint, failed mkdir, and exited permanently —
recording stayed dead until a manual restart.

- recorder: on mkdir failure, log and retry every 15s instead of exiting,
  so recording resumes once storage returns
- storage: add waitForStorage() and call it before the writers start, so
  startup waits (up to 120s) for the recordings dir to be present and
  writable; live view still starts if storage never appears
2026-10-15 07:09:11 +01:00

229 lines
6.3 KiB
Go

// NextNVR — MIT License
// Copyright (c) 2026 NextNVR Contributors
// SPDX-License-Identifier: MIT
// Lightweight NVR application for recording and monitoring IP cameras.
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
var (
// Version is set at build time via ldflags.
Version = "0.1.0"
// BuildTime is set at build time via ldflags.
BuildTime = "unknown"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("NextNVR v%s (built %s)", Version, BuildTime)
// Parse command-line flags.
configPath := flagConfigPath()
// Load or create configuration.
cfg, err := LoadConfig(configPath)
if err != nil {
log.Printf("No existing config found at %s — starting in setup mode", configPath)
cfg = DefaultConfig()
// Don't auto-save; wait for the user to complete setup via the web UI.
}
log.Printf("Server: %s", cfg.Server.Port)
log.Printf("Storage: %s (retention: %d days)", cfg.Storage.RecordingsPath, cfg.Storage.RetentionDays)
log.Printf("Cameras configured: %d", len(cfg.Cameras))
if cfg.Go2RTC.Enabled {
log.Printf("go2rtc: enabled on port %s (binary: %s)", cfg.Go2RTC.Port, cfg.Go2RTC.Binary)
}
// Signal handling for graceful shutdown.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// Initialize application.
app := &App{Config: cfg, ConfigPath: configPath}
// Start go2rtc if enabled.
if cfg.Go2RTC.Enabled {
go2rtc := NewGo2RTCManager(cfg.Go2RTC)
if err := go2rtc.Start(cfg.Cameras); err != nil {
log.Printf("go2rtc: start failed: %v", err)
}
app.go2rtc = go2rtc
}
// Wait for the recordings storage before starting any writers. On a slow
// boot (e.g. fsck after a power cut) the mount can appear tens of seconds
// late; this avoids the recorders failing against an unmounted, root-owned
// mountpoint. If it times out we still start — live view does not need
// storage, and the recorder retries on its own.
if st := waitForStorage(cfg.Storage.RecordingsPath, 120*time.Second); st.OK {
log.Printf("startup: storage ready — %s", cfg.Storage.RecordingsPath)
} else {
log.Printf("startup: storage NOT ready (%s) — starting anyway; recording will retry", st.Message)
}
// Start snapshot engine for all enabled cameras.
snaps := NewSnapshotEngine(cfg.Storage)
snaps.StartAll(cfg.Cameras)
app.snapshots = snaps
// Start FFmpeg recorder for all enabled cameras.
rec := NewRecorderManager(cfg.Storage)
rec.StartAll(cfg.Cameras)
app.recorder = rec
// Start retention cleaner.
cln := NewCleaner(cfg.Storage)
go cln.Start()
app.cleaner = cln
// Start storage monitor (alert-only; never stops recording or live view).
storeMon := NewStorageMonitor(cfg.Storage.RecordingsPath, 30*time.Second)
storeMon.Start()
app.storage = storeMon
// Start the HTTP server (main port, with auth).
app.server, _ = NewServer(cfg)
app.server.storage = storeMon
app.server.onReload = func() {
log.Println("Config reload — restarting services...")
if app.go2rtc != nil { app.go2rtc.Stop() }
if app.snapshots != nil { app.snapshots.StopAll() }
if app.recorder != nil { app.recorder.StopAll() }
// Read config under lock to get latest state.
app.server.mu.RLock()
liveCfg := *app.server.appConfig // copy, not pointer
app.server.mu.RUnlock()
// Handle viewer server toggle via viewer_port.
if liveCfg.Go2RTC.Enabled {
app.go2rtc = NewGo2RTCManager(liveCfg.Go2RTC)
app.go2rtc.Start(liveCfg.Cameras)
}
snaps := NewSnapshotEngine(liveCfg.Storage)
snaps.StartAll(liveCfg.Cameras)
app.snapshots = snaps
rec := NewRecorderManager(liveCfg.Storage)
rec.StartAll(liveCfg.Cameras)
app.recorder = rec
// Re-target the storage monitor if the recordings path changed.
if app.storage == nil || app.storage.Path() != liveCfg.Storage.RecordingsPath {
if app.storage != nil {
app.storage.Stop()
}
app.storage = NewStorageMonitor(liveCfg.Storage.RecordingsPath, 30*time.Second)
app.storage.Start()
app.server.storage = app.storage
}
log.Println("Config reload — services restarted.")
}
errCh := make(chan error, 1)
go func() {
errCh <- app.server.ListenAndServe()
}()
// Start viewer server only if enabled.
// Start viewer server always — access controlled by config at request level.
go func() {
v, err := app.server.StartViewerServer()
if err != nil { log.Printf("Viewer server: %v", err) }
app.viewerSrv = v
}()
select {
case sig := <-sigCh:
log.Printf("Received signal: %v — shutting down", sig)
case err := <-errCh:
if err != nil {
log.Printf("Server error: %v", err)
}
}
app.Shutdown()
log.Println("NextNVR stopped.")
}
// flagConfigPath resolves the config file path from CLI flags.
func flagConfigPath() string {
// Look for --config flag in os.Args.
for i, arg := range os.Args[1:] {
if arg == "--config" && i+1 < len(os.Args[1:]) {
return os.Args[i+2] // +2 because Args[0] is binary name, Args[1:] starts at index 1
}
if len(arg) > 9 && arg[:9] == "--config=" {
return arg[9:]
}
}
// Default: look next to the binary, then /opt/nextnvr.
if _, err := os.Stat("config.json"); err == nil {
return "config.json"
}
if _, err := os.Stat("/opt/nextnvr/config.json"); err == nil {
return "/opt/nextnvr/config.json"
}
return "config.json"
}
// App holds the application runtime state.
type App struct {
Config Config
ConfigPath string
server *Server
go2rtc *Go2RTCManager
snapshots *SnapshotEngine
recorder *RecorderManager
cleaner *Cleaner
storage *StorageMonitor
viewerSrv *http.Server
}
// StartServer initializes and starts the HTTP server.
func (a *App) StartServer() error {
srv, err := NewServer(a.Config)
if err != nil {
return fmt.Errorf("creating server: %w", err)
}
a.server = srv
return srv.ListenAndServe()
}
// StartViewerServer launches the viewer-only server.
func (a *App) StartViewerServer() (*http.Server, error) {
if a.server == nil {
return nil, fmt.Errorf("main server not initialized")
}
return a.server.StartViewerServer()
}
// Shutdown performs a graceful shutdown of all services.
func (a *App) Shutdown() {
if a.recorder != nil {
a.recorder.StopAll()
}
if a.cleaner != nil {
a.cleaner.Stop()
}
if a.snapshots != nil {
a.snapshots.StopAll()
}
if a.go2rtc != nil {
a.go2rtc.Stop()
}
if a.server != nil {
a.server.Close()
}
}