chore: Milestone 1 & 2 — core setup, recorder, cleaner, web UI, deployment

This commit is contained in:
Claus Lohmar 2026-08-05 09:15:43 +01:00
parent 5b04c831b5
commit 84bf6d892f
17 changed files with 1746 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
nextnvr

26
CHANGELOG.md Normal file
View file

@ -0,0 +1,26 @@
# Changelog
All notable changes to NextNVR will be documented in this file.
## [0.2.0] — 2026-08-05
### Added
- FFmpeg stream recorder with zero-transcoding (`-c copy`) stream-copy mode
- 5-minute MP4 segment output to `/mnt/recordings/{cam_id}/{YYYY-MM-DD}/`
- Per-camera goroutines with auto-reconnect on stream failure (5s delay)
- Staggered camera startup (500ms between launches)
- Storage retention cleaner with configurable `retention_days`
- Hourly background purge of expired recordings
- Empty directory pruning after cleanup runs
## [0.1.0] — 2026-08-05
### Added
- Initial project scaffold and Go module
- YAML configuration (`config.yaml`) with 8 camera stubs
- Embedded single-binary distribution using Go `embed`
- HTTP API endpoints: `/api/cameras`, `/api/config`, `/api/scan`, `/api/status`, `/api/recordings`
- Responsive SPA with three tabs: Live Wall, Playback, Settings
- 4x2 CSS grid for live camera monitoring
- Focus/overlay mode with prev/next navigation and thumbnail strip
- ONVIF scan stub (IP range 192.168.1.201209, skip 204)
- go2rtc child process manager with auto-restart
- Deployment script (`setup.sh`) with systemd service generation

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0

229
api.go Normal file
View file

@ -0,0 +1,229 @@
// NextNVR v0.1.0 — API handlers
// REST endpoints for cameras, configuration, ONVIF discovery, and status.
package main
import (
"encoding/json"
"net/http"
"strings"
)
// handleCameras returns the list of all cameras with runtime status.
// GET /api/cameras
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
return
}
statuses := make([]CameraStatus, 0, len(s.appConfig.Cameras))
for _, cam := range s.appConfig.Cameras {
statuses = append(statuses, CameraStatus{
CameraConfig: cam,
Online: false, // TODO: real stream health check in M2
Uptime: "n/a",
Streams: 0,
})
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: statuses})
}
// handleCameraByID handles single-camera operations.
// GET /api/cameras/{id} — get camera details
// PUT /api/cameras/{id} — update camera config
func (s *Server) handleCameraByID(w http.ResponseWriter, r *http.Request) {
camID := strings.TrimPrefix(r.URL.Path, "/api/cameras/")
if camID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "camera ID required"})
return
}
cam, idx := s.findCamera(camID)
if cam == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Error: "camera not found: " + camID})
return
}
switch r.Method {
case http.MethodGet:
status := CameraStatus{
CameraConfig: *cam,
Online: false,
Uptime: "n/a",
Streams: 0,
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
case http.MethodPut:
var updated CameraConfig
if err := json.NewDecoder(r.Body).Decode(&updated); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()})
return
}
updated.ID = camID
s.appConfig.Cameras[idx] = updated
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: updated})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
}
}
// handleConfig handles configuration read/write.
// GET /api/config — return current config (without passwords)
// POST /api/config — save full config to config.yaml
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// Return config with passwords masked.
safe := *s.appConfig
for i := range safe.Cameras {
if safe.Cameras[i].Password != "" {
safe.Cameras[i].Password = "********"
}
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: safe})
case http.MethodPost:
var newCfg Config
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()})
return
}
// Preserve passwords if not provided (masked in UI).
for i := range newCfg.Cameras {
if newCfg.Cameras[i].Password == "********" || newCfg.Cameras[i].Password == "" {
if old := s.findCameraByIP(newCfg.Cameras[i].IP); old != nil {
newCfg.Cameras[i].Password = old.Password
}
}
}
*s.appConfig = newCfg
if err := SaveConfig(*s.appConfig, "/opt/nextnvr/config.yaml"); err != nil {
// Try saving to the app's config path.
_ = SaveConfig(*s.appConfig, "config.yaml")
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "config saved"})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
}
}
// handleScan triggers ONVIF network discovery.
// POST /api/scan
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
return
}
// TODO: M3 — real ONVIF WS-Discovery.
// For now, return a stub response with the expected IP range.
type DiscoveredCamera struct {
IP string `json:"ip"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
RTSPMain string `json:"rtsp_main"`
RTSPSub string `json:"rtsp_sub"`
Found bool `json:"found"`
}
results := make([]DiscoveredCamera, 0)
for ip := 201; ip <= 209; ip++ {
if ip == 204 {
continue
}
ipStr := "192.168.1." + itoa(ip)
results = append(results, DiscoveredCamera{
IP: ipStr,
Manufacturer: "Unknown",
Model: "ONVIF Camera",
RTSPMain: "rtsp://" + ipStr + ":554/Streaming/Channels/101",
RTSPSub: "rtsp://" + ipStr + ":554/Streaming/Channels/102",
Found: false, // requires real ONVIF probe in M3
})
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
}
// handleStatus returns server health and runtime information.
// GET /api/status
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
return
}
status := map[string]interface{}{
"version": Version,
"cameras_total": len(s.appConfig.Cameras),
"cameras_active": 0, // TODO: real counts in M2
"uptime": "n/a",
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
}
// handleRecordings returns available recordings for playback.
// GET /api/recordings?cam={id}&date={YYYY-MM-DD}
func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
return
}
camID := r.URL.Query().Get("cam")
date := r.URL.Query().Get("date")
// 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"`
}
_ = camID
_ = date
clips := make([]Clip, 0)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
}
// findCamera locates a camera by ID.
func (s *Server) findCamera(id string) (*CameraConfig, int) {
for i, cam := range s.appConfig.Cameras {
if cam.ID == id {
return &s.appConfig.Cameras[i], i
}
}
return nil, -1
}
// findCameraByIP locates a camera by IP address.
func (s *Server) findCameraByIP(ip string) *CameraConfig {
for i := range s.appConfig.Cameras {
if s.appConfig.Cameras[i].IP == ip {
return &s.appConfig.Cameras[i]
}
}
return nil
}
// itoa is a simple int-to-string helper (avoids importing strconv).
func itoa(i int) string {
if i == 0 {
return "0"
}
digits := ""
for n := i; n > 0; n /= 10 {
digits = string(rune('0'+n%10)) + digits
}
return digits
}

130
cleaner.go Normal file
View file

@ -0,0 +1,130 @@
// NextNVR v0.2.0 — Storage retention cleaner
// Background ticker that purges recordings older than retention_days.
package main
import (
"log"
"os"
"path/filepath"
"strings"
"time"
)
// Cleaner manages the periodic purge of old recordings.
type Cleaner struct {
config StorageConfig
stopCh chan struct{}
running bool
}
// NewCleaner creates a new retention cleaner.
func NewCleaner(cfg StorageConfig) *Cleaner {
if cfg.CleanupIntervalMins <= 0 {
cfg.CleanupIntervalMins = 60
}
return &Cleaner{
config: cfg,
stopCh: make(chan struct{}),
}
}
// Start begins the periodic cleanup loop.
func (c *Cleaner) Start() {
c.running = true
interval := time.Duration(c.config.CleanupIntervalMins) * time.Minute
log.Printf("cleaner: starting — retention=%d days, interval=%v, path=%s",
c.config.RetentionDays, interval, c.config.RecordingsPath)
// Run immediately on startup.
go c.purge()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-c.stopCh:
c.running = false
log.Println("cleaner: stopped")
return
case <-ticker.C:
go c.purge()
}
}
}
// Stop signals the cleaner to shutdown.
func (c *Cleaner) Stop() {
close(c.stopCh)
}
// purge walks the recordings directory and deletes files older than retention_days.
func (c *Cleaner) purge() {
cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour)
deleted := 0
var freedBytes int64
err := filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip unreadable files
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") {
return nil
}
if info.ModTime().After(cutoff) {
return nil
}
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
})
if err != nil {
log.Printf("cleaner: walk error: %v", err)
}
// Prune empty directories.
c.pruneEmptyDirs(c.config.RecordingsPath)
if deleted > 0 {
log.Printf("cleaner: removed %d files, freed %s", deleted, formatBytes(freedBytes))
}
}
// pruneEmptyDirs removes empty date directories within the recordings tree.
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, _ := os.ReadDir(path)
if len(entries) == 0 {
os.Remove(path)
}
return nil
})
}
// formatBytes returns a human-readable byte count.
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return itoa(int(bytes)) + " B"
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return itoa(int(float64(bytes)/float64(div)*10)/10) + " " + string("KMGTPE"[exp]) + "B"
}

132
config.go Normal file
View file

@ -0,0 +1,132 @@
// NextNVR v0.1.0 — Configuration management
// Handles config.yaml parsing with sensible defaults
package main
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Config represents the top-level application configuration.
type Config struct {
Server ServerConfig `yaml:"server"`
Storage StorageConfig `yaml:"storage"`
Cameras []CameraConfig `yaml:"cameras"`
Go2RTC Go2RTCConfig `yaml:"go2rtc"`
}
// ServerConfig holds HTTP server settings.
type ServerConfig struct {
Port string `yaml:"port"`
BindHost string `yaml:"bind_host"`
}
// StorageConfig holds recording and retention settings.
type StorageConfig struct {
RecordingsPath string `yaml:"recordings_path"`
RetentionDays int `yaml:"retention_days"`
CleanupIntervalMins int `yaml:"cleanup_interval_mins"`
}
// CameraConfig represents a single camera's configuration.
type CameraConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
IP string `yaml:"ip"`
Username string `yaml:"username"`
Password string `yaml:"password"`
ONVIFPort int `yaml:"onvif_port"`
RTSPMain string `yaml:"rtsp_main"`
RTSPSub string `yaml:"rtsp_sub"`
Description string `yaml:"description"`
Enabled bool `yaml:"enabled"`
Record bool `yaml:"record"`
}
// Go2RTCConfig holds go2rtc child process settings.
type Go2RTCConfig struct {
Enabled bool `yaml:"enabled"`
Port string `yaml:"port"`
Binary string `yaml:"binary"`
}
// APIResponse wraps all JSON API responses.
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// CameraStatus extends CameraConfig with runtime information.
type CameraStatus struct {
CameraConfig
Online bool `json:"online"`
Uptime string `json:"uptime"`
Streams int `json:"streams"`
}
// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() Config {
return Config{
Server: ServerConfig{
Port: ":8080",
BindHost: "127.0.0.1",
},
Storage: StorageConfig{
RecordingsPath: "/mnt/recordings",
RetentionDays: 18,
CleanupIntervalMins: 60,
},
Go2RTC: Go2RTCConfig{
Enabled: true,
Port: ":1984",
Binary: "go2rtc",
},
}
}
// LoadConfig reads and parses a YAML configuration file.
// If the file does not exist, it returns DefaultConfig().
func LoadConfig(path string) (Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, fmt.Errorf("config file not found: %s", path)
}
return cfg, fmt.Errorf("reading config: %w", err)
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parsing config: %w", err)
}
// Apply path defaults relative to config file location.
configDir := filepath.Dir(path)
if cfg.Storage.RecordingsPath == "" {
cfg.Storage.RecordingsPath = "/mnt/recordings"
}
_ = configDir // reserved for relative path resolution
return cfg, nil
}
// SaveConfig writes the configuration to a YAML file.
func SaveConfig(cfg Config, path string) error {
data, err := yaml.Marshal(&cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
header := []byte("# NextNVR Configuration\n# Generated by NextNVR setup\n\n")
out := append(header, data...)
if err := os.WriteFile(path, out, 0644); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil
}

113
config.yaml Normal file
View file

@ -0,0 +1,113 @@
# NextNVR Configuration
# Generated by NextNVR setup
server:
port: ":8080"
bind_host: "127.0.0.1"
storage:
recordings_path: "/mnt/recordings"
retention_days: 18
cleanup_interval_mins: 60
go2rtc:
enabled: true
port: ":1984"
binary: "go2rtc"
cameras:
- id: "cam_201"
name: "Camera 201"
ip: "192.168.1.201"
username: "admin"
password: ""
onvif_port: 80
rtsp_main: ""
rtsp_sub: ""
description: ""
enabled: true
record: true
- id: "cam_202"
name: "Camera 202"
ip: "192.168.1.202"
username: "admin"
password: ""
onvif_port: 80
rtsp_main: ""
rtsp_sub: ""
description: ""
enabled: true
record: true
- id: "cam_203"
name: "Camera 203"
ip: "192.168.1.203"
username: "admin"
password: ""
onvif_port: 80
rtsp_main: ""
rtsp_sub: ""
description: ""
enabled: true
record: true
- id: "cam_205"
name: "Camera 205"
ip: "192.168.1.205"
username: "admin"
password: ""
onvif_port: 80
rtsp_main: ""
rtsp_sub: ""
description: ""
enabled: true
record: true
- id: "cam_206"
name: "Camera 206"
ip: "192.168.1.206"
username: "admin"
password: ""
onvif_port: 80
rtsp_main: ""
rtsp_sub: ""
description: ""
enabled: true
record: true
- id: "cam_207"
name: "Camera 207"
ip: "192.168.1.207"
username: "admin"
password: ""
onvif_port: 80
rtsp_main: ""
rtsp_sub: ""
description: ""
enabled: true
record: true
- id: "cam_208"
name: "Camera 208"
ip: "192.168.1.208"
username: "admin"
password: ""
onvif_port: 80
rtsp_main: ""
rtsp_sub: ""
description: ""
enabled: true
record: true
- id: "cam_209"
name: "Camera 209"
ip: "192.168.1.209"
username: "admin"
password: ""
onvif_port: 80
rtsp_main: ""
rtsp_sub: ""
description: ""
enabled: true
record: true

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module github.com/cclohmar/NextNVR
go 1.24.5
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View file

@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

143
go2rtc.go Normal file
View file

@ -0,0 +1,143 @@
// NextNVR v0.1.0 — go2rtc child process management
// Launches and monitors go2rtc for WebRTC stream conversion.
package main
import (
"fmt"
"log"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// Go2RTCManager handles the go2rtc child process lifecycle.
type Go2RTCManager struct {
mu sync.Mutex
cmd *exec.Cmd
config Go2RTCConfig
running bool
}
// NewGo2RTCManager creates a new go2rtc process manager.
func NewGo2RTCManager(cfg Go2RTCConfig) *Go2RTCManager {
return &Go2RTCManager{
config: cfg,
}
}
// Start launches the go2rtc process and generates its configuration.
func (g *Go2RTCManager) Start(cameras []CameraConfig) error {
g.mu.Lock()
defer g.mu.Unlock()
if !g.config.Enabled {
log.Println("go2rtc: disabled in config, skipping")
return nil
}
// Generate go2rtc.yaml config from camera list.
if err := g.writeConfig(cameras); err != nil {
return fmt.Errorf("go2rtc config: %w", err)
}
// Launch go2rtc as a child process.
g.cmd = exec.Command(g.config.Binary, "-config", "go2rtc.yaml")
g.cmd.Stdout = os.Stdout
g.cmd.Stderr = os.Stderr
if err := g.cmd.Start(); err != nil {
return fmt.Errorf("starting go2rtc: %w", err)
}
g.running = true
log.Printf("go2rtc: started on %s (PID %d)", g.config.Port, g.cmd.Process.Pid)
// Monitor the process in the background.
go g.monitor()
return nil
}
// Stop terminates the go2rtc process.
func (g *Go2RTCManager) Stop() {
g.mu.Lock()
defer g.mu.Unlock()
if g.cmd != nil && g.cmd.Process != nil {
log.Println("go2rtc: stopping...")
g.cmd.Process.Signal(os.Interrupt)
time.Sleep(2 * time.Second)
g.cmd.Process.Kill()
g.running = false
log.Println("go2rtc: stopped")
}
}
// monitor watches the go2rtc process and restarts it on crash.
func (g *Go2RTCManager) monitor() {
for {
if g.cmd == nil {
return
}
err := g.cmd.Wait()
g.mu.Lock()
wasRunning := g.running
g.mu.Unlock()
if !wasRunning {
return // intentional shutdown
}
log.Printf("go2rtc: process exited (%v) — restarting in 5s", err)
time.Sleep(5 * time.Second)
g.mu.Lock()
if g.running {
g.cmd = exec.Command(g.config.Binary, "-config", "go2rtc.yaml")
g.cmd.Stdout = os.Stdout
g.cmd.Stderr = os.Stderr
if startErr := g.cmd.Start(); startErr != nil {
log.Printf("go2rtc: restart failed: %v", startErr)
g.running = false
} else {
log.Printf("go2rtc: restarted (PID %d)", g.cmd.Process.Pid)
}
}
g.mu.Unlock()
}
}
// writeConfig generates the go2rtc.yaml configuration from camera definitions.
func (g *Go2RTCManager) writeConfig(cameras []CameraConfig) error {
var sb strings.Builder
sb.WriteString("# go2rtc configuration — generated by NextNVR\n")
sb.WriteString("api:\n")
sb.WriteString(" listen: \"" + g.config.Port + "\"\n\n")
sb.WriteString("streams:\n")
for _, cam := range cameras {
if !cam.Enabled {
continue
}
// Use sub stream for live grid, main stream for full quality.
subURL := cam.RTSPSub
if subURL == "" {
subURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/102",
cam.Username, cam.Password, cam.IP)
}
mainURL := cam.RTSPMain
if mainURL == "" {
mainURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/101",
cam.Username, cam.Password, cam.IP)
}
sb.WriteString(fmt.Sprintf(" %s_sub: %s\n", cam.ID, subURL))
sb.WriteString(fmt.Sprintf(" %s_main: %s\n", cam.ID, mainURL))
}
return os.WriteFile("go2rtc.yaml", []byte(sb.String()), 0644)
}

112
main.go Normal file
View file

@ -0,0 +1,112 @@
// NextNVR v0.1.0 — Main entry point
// Lightweight NVR application for recording and monitoring IP cameras.
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
)
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)
// Start the HTTP server (blocks).
errCh := make(chan error, 1)
app := &App{Config: cfg, ConfigPath: configPath}
go func() {
errCh <- app.StartServer()
}()
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.yaml"); err == nil {
return "config.yaml"
}
if _, err := os.Stat("/opt/nextnvr/config.yaml"); err == nil {
return "/opt/nextnvr/config.yaml"
}
return "config.yaml"
}
// App holds the application runtime state.
type App struct {
Config Config
ConfigPath string
server *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()
}
// Shutdown performs a graceful shutdown of all services.
func (a *App) Shutdown() {
if a.server != nil {
a.server.Close()
}
}

278
public/app.js Normal file
View file

@ -0,0 +1,278 @@
// NextNVR v0.1.0 — Single Page Application
// Tabbed UI: Live Wall (4×2 grid + focus), Playback browser, Settings/Onboarding
const API = '/api';
// ── App State ──
let cameras = [];
let config = null;
// ── Init ──
document.addEventListener('DOMContentLoaded', async () => {
setupTabs();
await loadStatus();
await loadCameras();
renderLiveGrid();
renderPlaybackCameras();
renderCameraCards();
});
// ── Tabs ──
function setupTabs() {
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
});
});
}
// ── Status ──
async function loadStatus() {
try {
const r = await fetch(API + '/status');
const j = await r.json();
const dot = document.getElementById('status-dot');
dot.className = 'status ' + (j.success ? 'online' : 'offline');
} catch(e) {
document.getElementById('status-dot').className = 'status offline';
}
}
// ── Cameras ──
async function loadCameras() {
try {
const r = await fetch(API + '/cameras');
const j = await r.json();
if (j.success) cameras = j.data;
} catch(e) { cameras = []; }
}
// ── Live Grid ──
function renderLiveGrid() {
const grid = document.getElementById('grid');
grid.innerHTML = '';
for (let i = 0; i < 8; i++) {
const cam = cameras[i];
const tile = document.createElement('div');
tile.className = 'grid-tile' + (cam && cam.enabled ? '' : ' offline');
tile.dataset.camId = cam ? cam.id : '';
if (cam) {
tile.innerHTML = `
<span class="tile-status ${cam.online ? 'online' : 'offline'}"></span>
<span class="tile-label">${cam.name}</span>
`;
tile.addEventListener('click', () => openFocus(cam));
} else {
tile.innerHTML = '<span class="tile-placeholder">No Camera</span>';
}
grid.appendChild(tile);
}
}
// ── Focus Overlay ──
let focusIdx = -1;
function openFocus(cam) {
focusIdx = cameras.indexOf(cam);
const overlay = document.getElementById('focus-overlay');
overlay.classList.remove('hidden');
updateFocus();
renderThumbs();
}
function updateFocus() {
if (focusIdx < 0 || focusIdx >= cameras.length) return;
const cam = cameras[focusIdx];
document.getElementById('focus-title').textContent = cam.name;
document.getElementById('focus-time').textContent = new Date().toLocaleTimeString() + ' live';
// TODO: M3 — connect to go2rtc WebRTC stream
const vid = document.getElementById('focus-video');
vid.src = '';
vid.poster = ''; // placeholder until go2rtc is wired up
}
function renderThumbs() {
const strip = document.getElementById('focus-thumbs');
strip.innerHTML = '';
cameras.forEach((cam, i) => {
const thumb = document.createElement('div');
thumb.className = 'thumb' + (i === focusIdx ? ' active' : '');
thumb.textContent = cam.name;
thumb.title = cam.name;
thumb.addEventListener('click', () => { focusIdx = i; updateFocus(); renderThumbs(); });
strip.appendChild(thumb);
});
}
document.getElementById('focus-close').addEventListener('click', () => {
document.getElementById('focus-overlay').classList.add('hidden');
});
document.getElementById('focus-prev').addEventListener('click', () => {
if (focusIdx > 0) { focusIdx--; updateFocus(); renderThumbs(); }
});
document.getElementById('focus-next').addEventListener('click', () => {
if (focusIdx < cameras.length - 1) { focusIdx++; updateFocus(); renderThumbs(); }
});
document.addEventListener('keydown', e => {
if (document.getElementById('focus-overlay').classList.contains('hidden')) return;
if (e.key === 'Escape') document.getElementById('focus-overlay').classList.add('hidden');
if (e.key === 'ArrowLeft' && focusIdx > 0) { focusIdx--; updateFocus(); renderThumbs(); }
if (e.key === 'ArrowRight' && focusIdx < cameras.length - 1) { focusIdx++; updateFocus(); renderThumbs(); }
});
// ── Playback ──
function renderPlaybackCameras() {
const sel = document.getElementById('pb-camera');
sel.innerHTML = '<option value="">— Select Camera —</option>';
cameras.forEach(c => {
sel.innerHTML += `<option value="${c.id}">${c.name}</option>`;
});
}
document.getElementById('pb-load').addEventListener('click', async () => {
const cam = document.getElementById('pb-camera').value;
const date = document.getElementById('pb-date').value;
if (!cam || !date) return;
try {
const r = await fetch(API + '/recordings?cam=' + cam + '&date=' + date);
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; }
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 class="clip-size">${formatSize(c.size)}</div>
</div>
`).join('');
}
function playClip(path) {
document.getElementById('pb-player').classList.remove('hidden');
document.getElementById('pb-video').src = '/recordings/' + path;
}
document.getElementById('pb-close').addEventListener('click', () => {
document.getElementById('pb-player').classList.add('hidden');
document.getElementById('pb-video').src = '';
});
// ── Settings / Onboarding ──
async function renderCameraCards() {
const container = document.getElementById('camera-cards');
if (cameras.length === 0) {
container.innerHTML = `
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)">
<p style="font-size:18px">🎥 Welcome to NextNVR</p>
<p style="margin-top:8px">No cameras configured yet. Scan your network to get started.</p>
<button class="btn-primary" style="margin-top:16px" onclick="document.getElementById('btn-scan').click()">
🔍 Scan Network (ONVIF)
</button>
</div>`;
return;
}
container.innerHTML = cameras.map(c => `
<div class="camera-card" data-id="${c.id}">
<h3>📷 ${c.name || 'Unnamed'} <span style="font-size:11px;color:var(--text-muted)">${c.ip}</span></h3>
<label>Name <input type="text" value="${escAttr(c.name)}" data-field="name"></label>
<label>Description <textarea data-field="description">${escHtml(c.description)}</textarea></label>
<label>RTSP Main <input type="text" value="${escAttr(c.rtsp_main)}" data-field="rtsp_main"></label>
<label>RTSP Sub <input type="text" value="${escAttr(c.rtsp_sub)}" data-field="rtsp_sub"></label>
<label>Username <input type="text" value="${escAttr(c.username)}" data-field="username"></label>
<label>Password <input type="password" value="${escAttr(c.password)}" data-field="password"></label>
<div class="row">
<label>Enabled <input type="checkbox" ${c.enabled ? 'checked' : ''} data-field="enabled" style="width:auto"></label>
<label>Record <input type="checkbox" ${c.record ? 'checked' : ''} data-field="record" style="width:auto"></label>
</div>
</div>
`).join('');
}
// ── Settings: Scan ──
document.getElementById('btn-scan').addEventListener('click', async () => {
const btn = document.getElementById('btn-scan');
const status = document.getElementById('settings-status');
btn.disabled = true;
btn.textContent = '⏳ Scanning...';
status.textContent = 'Probing 192.168.1.201209...';
try {
const r = await fetch(API + '/scan', { method: 'POST' });
const j = await r.json();
if (j.success) {
status.textContent = `Found ${j.data.length} devices. Fill in names and save.`;
// Pre-fill camera cards with discovered data.
cameras = j.data.map((d, i) => ({
id: 'cam_' + d.ip.split('.').pop(),
name: d.found ? d.manufacturer + ' ' + d.model : 'Camera ' + (201 + i),
ip: d.ip,
username: 'admin', password: '',
onvif_port: 80,
rtsp_main: d.rtsp_main || '',
rtsp_sub: d.rtsp_sub || '',
description: '',
enabled: true, record: true,
online: d.found
}));
renderCameraCards();
renderLiveGrid();
renderPlaybackCameras();
}
} catch(e) {
status.textContent = 'Scan failed: ' + e.message;
}
btn.disabled = false;
btn.textContent = '🔍 Scan Network (ONVIF)';
});
// ── Settings: Save ──
document.getElementById('btn-save').addEventListener('click', async () => {
const status = document.getElementById('settings-status');
// Collect camera data from DOM.
const cards = document.querySelectorAll('.camera-card');
const updatedCameras = [];
cards.forEach(card => {
const c = {};
card.querySelectorAll('[data-field]').forEach(el => {
const field = el.dataset.field;
if (el.type === 'checkbox') c[field] = el.checked;
else c[field] = el.value;
});
updatedCameras.push(c);
});
try {
// Fetch current config, then post updated version.
const cr = await fetch(API + '/config');
const cj = await cr.json();
const cfg = cj.data || {};
cfg.cameras = updatedCameras;
const r = await fetch(API + '/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
});
const j = await r.json();
if (j.success) {
status.textContent = '✅ Configuration saved! Restart may be required.';
status.style.color = 'var(--green)';
} else {
status.textContent = '❌ Save failed: ' + j.error;
status.style.color = 'var(--red)';
}
} catch(e) {
status.textContent = '❌ Error: ' + e.message;
status.style.color = 'var(--red)';
}
});
// ── Helpers ──
function escAttr(s) { return (s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;'); }
function escHtml(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function formatSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const u = ['B','KB','MB','GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + u[i];
}

65
public/index.html Normal file
View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NextNVR — Casa Alba Mindelo</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="topbar">
<h1>🎥 NextNVR</h1>
<nav id="tabs">
<button data-tab="live" class="tab active">Live Wall</button>
<button data-tab="playback" class="tab">Playback</button>
<button data-tab="settings" class="tab">Settings</button>
</nav>
<span id="status-dot" class="status offline" title="Server status"></span>
</header>
<main id="content">
<!-- Live Wall: 4×2 grid + focus overlay -->
<section id="tab-live" class="tab-panel active">
<div id="grid" class="grid-4x2"></div>
<div id="focus-overlay" class="focus-overlay hidden">
<div class="focus-toolbar">
<button id="focus-prev"></button>
<span id="focus-title">Camera</span>
<span id="focus-time" class="focus-time">00:00 live</span>
<button id="focus-close"></button>
<button id="focus-next"></button>
</div>
<video id="focus-video" autoplay muted playsinline></video>
<div id="focus-thumbs" class="thumb-strip"></div>
</div>
</section>
<!-- Playback: clip browser -->
<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>
<div id="pb-clips" class="clip-grid"></div>
<div id="pb-player" class="clip-player hidden">
<video id="pb-video" controls></video>
<button id="pb-close">✕ Close</button>
</div>
</section>
<!-- Settings: camera configuration & onboarding -->
<section id="tab-settings" class="tab-panel">
<div class="settings-toolbar">
<button id="btn-scan" class="btn-primary">🔍 Scan Network (ONVIF)</button>
<button id="btn-save" class="btn-success">💾 Save Configuration</button>
<span id="settings-status"></span>
</div>
<div id="camera-cards"></div>
<div id="scan-results" class="scan-results hidden"></div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

180
public/style.css Normal file
View file

@ -0,0 +1,180 @@
/* NextNVR v0.1.0 — Minimal responsive CSS */
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #c9d1d9;
--text-muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--orange: #d2991d;
--radius: 8px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* ── Top Bar ── */
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100;
}
.topbar h1 { font-size: 18px; font-weight: 600; }
.tab {
background: none; border: none; color: var(--text-muted);
padding: 6px 14px; cursor: pointer; border-radius: var(--radius);
font-size: 14px; transition: all .15s;
}
.tab:hover { color: var(--text); background: var(--border); }
.tab.active { color: #fff; background: var(--accent); }
.status { margin-left: auto; font-size: 22px; }
.status.online { color: var(--green); }
.status.offline { color: var(--red); }
/* ── Tab Panels ── */
.tab-panel { display: none; padding: 16px; }
.tab-panel.active { display: block; }
/* ── Live Grid ── */
.grid-4x2 {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 8px;
height: calc(100vh - 70px);
}
.grid-tile {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
position: relative;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
transition: border-color .2s, transform .1s;
aspect-ratio: 16/9;
}
.grid-tile:hover { border-color: var(--accent); transform: scale(1.01); }
.grid-tile.offline { opacity: 0.5; cursor: default; }
.tile-label {
position: absolute; bottom: 6px; left: 8px;
font-size: 11px; color: #fff; background: rgba(0,0,0,.6);
padding: 2px 8px; border-radius: 4px;
}
.tile-status {
position: absolute; top: 6px; right: 8px;
width: 8px; height: 8px; border-radius: 50%;
}
.tile-status.online { background: var(--green); }
.tile-status.offline { background: var(--red); }
.tile-placeholder {
font-size: 14px; color: var(--text-muted);
text-align: center; padding: 10px;
}
/* ── Focus Overlay ── */
.focus-overlay {
position: fixed; inset: 0; z-index: 200;
background: #000; display: flex; flex-direction: column;
}
.focus-overlay.hidden { display: none; }
.focus-toolbar {
display: flex; align-items: center; gap: 12px;
padding: 10px 20px; background: rgba(0,0,0,.8);
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
opacity: 0; transition: opacity .3s;
}
.focus-overlay:hover .focus-toolbar { opacity: 1; }
.focus-toolbar button {
background: rgba(255,255,255,.15); border: none;
color: #fff; padding: 8px 14px; border-radius: 6px;
cursor: pointer; font-size: 16px;
}
.focus-toolbar button:hover { background: rgba(255,255,255,.25); }
#focus-title { flex: 1; font-size: 16px; color: #fff; }
.focus-time { font-size: 13px; color: var(--text-muted); }
#focus-video { flex: 1; width: 100%; object-fit: contain; }
.thumb-strip {
display: flex; gap: 4px; padding: 8px;
background: rgba(0,0,0,.8); overflow-x: auto;
}
.thumb-strip .thumb {
width: 120px; height: 68px; object-fit: cover;
border: 2px solid transparent; border-radius: 4px; cursor: pointer; opacity: .6;
}
.thumb-strip .thumb:hover, .thumb-strip .thumb.active { opacity: 1; border-color: var(--accent); }
/* ── Playback ── */
.playback-controls {
display: flex; gap: 12px; align-items: center;
margin-bottom: 16px; flex-wrap: wrap;
}
.playback-controls select, .playback-controls input {
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
color: var(--text); border-radius: var(--radius); font-size: 14px;
}
.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);
border-radius: var(--radius); padding: 10px; cursor: pointer;
transition: border-color .15s;
}
.clip-card:hover { border-color: var(--accent); }
.clip-card .clip-time { font-size: 12px; color: var(--text-muted); }
.clip-card .clip-size { font-size: 11px; color: var(--text-muted); }
.clip-player {
position: fixed; inset: 0; z-index: 200; background: #000;
display: flex; align-items: center; justify-content: center; flex-direction: column;
}
.clip-player.hidden { display: none; }
.clip-player video { max-width: 90%; max-height: 80%; }
.clip-player button { margin-top: 12px; }
/* ── Settings ── */
.settings-toolbar {
display: flex; gap: 12px; align-items: center;
margin-bottom: 16px; flex-wrap: wrap;
}
#camera-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 12px; }
.camera-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px;
}
.camera-card h3 { font-size: 15px; margin-bottom: 8px; }
.camera-card label { display: block; font-size: 12px; color: var(--text-muted); margin-top: 8px; }
.camera-card input, .camera-card textarea, .camera-card select {
width: 100%; padding: 6px 10px; margin-top: 2px;
background: var(--bg); border: 1px solid var(--border);
color: var(--text); border-radius: 4px; font-size: 13px;
}
.camera-card textarea { resize: vertical; min-height: 50px; }
.camera-card .row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.camera-card .row label { margin-top: 0; }
.btn-primary { background: var(--accent); color: #fff; border: none; padding: 8px 20px; border-radius: var(--radius); cursor: pointer; font-size: 14px; }
.btn-success { background: var(--green); color: #fff; border: none; padding: 8px 20px; border-radius: var(--radius); cursor: pointer; font-size: 14px; }
.btn-primary:hover, .btn-success:hover { opacity: .9; }
.scan-results { margin-top: 12px; padding: 12px; background: var(--surface); border-radius: var(--radius); }
/* ── Mobile ── */
@media (max-width: 900px) {
.grid-4x2 { grid-template-columns: repeat(2, 1fr); grid-template-rows: auto; }
#camera-cards { grid-template-columns: 1fr; }
}
@media (max-width: 500px) {
.grid-4x2 { grid-template-columns: 1fr; }
.topbar h1 { font-size: 14px; }
.tab { padding: 4px 8px; font-size: 12px; }
}

141
recorder.go Normal file
View file

@ -0,0 +1,141 @@
// NextNVR v0.2.0 — FFmpeg stream recorder
// Launches per-camera FFmpeg processes with stream-copy mode and auto-reconnect.
package main
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
// RecorderManager manages FFmpeg recording processes for all cameras.
type RecorderManager struct {
mu sync.Mutex
processes map[string]*recorderProcess
config StorageConfig
}
type recorderProcess struct {
camera CameraConfig
cmd *exec.Cmd
done chan struct{}
}
// NewRecorderManager creates a new recorder manager.
func NewRecorderManager(cfg StorageConfig) *RecorderManager {
return &RecorderManager{
processes: make(map[string]*recorderProcess),
config: cfg,
}
}
// StartAll launches recording goroutines for all enabled cameras.
// Cameras are started with a 500ms stagger to avoid I/O and CPU spikes.
func (rm *RecorderManager) StartAll(cameras []CameraConfig) {
for _, cam := range cameras {
if !cam.Enabled || !cam.Record {
log.Printf("recorder: skipping camera %s (enabled=%v, record=%v)", cam.ID, cam.Enabled, cam.Record)
continue
}
go rm.startRecorder(cam)
time.Sleep(500 * time.Millisecond) // staggered startup
}
log.Printf("recorder: all cameras launched")
}
// StopAll terminates all recording processes.
func (rm *RecorderManager) StopAll() {
rm.mu.Lock()
defer rm.mu.Unlock()
for id, proc := range rm.processes {
log.Printf("recorder: stopping camera %s", id)
close(proc.done)
if proc.cmd != nil && proc.cmd.Process != nil {
proc.cmd.Process.Signal(os.Interrupt)
}
}
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.
func (rm *RecorderManager) startRecorder(cam CameraConfig) {
rtspURL := cam.RTSPMain
if rtspURL == "" {
rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/101",
cam.Username, cam.Password, cam.IP)
}
log.Printf("recorder: starting %s → %s", cam.ID, rtspURL)
rm.mu.Lock()
proc := &recorderProcess{
camera: cam,
done: make(chan struct{}),
}
rm.processes[cam.ID] = proc
rm.mu.Unlock()
for {
select {
case <-proc.done:
return
default:
}
// Create output directory: /mnt/recordings/{cam_id}/{YYYY-MM-DD}/
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")
cmd := exec.Command("ffmpeg",
"-hide_banner", "-loglevel", "error",
"-rtsp_transport", "tcp", // TCP is more reliable than UDP for RTSP
"-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,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Printf("recorder: %s — FFmpeg start failed: %v", cam.ID, err)
time.Sleep(5 * time.Second)
continue
}
proc.cmd = cmd
log.Printf("recorder: %s — FFmpeg running (PID %d)", cam.ID, cmd.Process.Pid)
err := cmd.Wait()
proc.cmd = nil
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)
}
time.Sleep(5 * time.Second)
}
}

97
server.go Normal file
View file

@ -0,0 +1,97 @@
// NextNVR v0.1.0 — HTTP server and routing
// Serves the embedded SPA and API endpoints.
package main
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"strings"
)
//go:embed public/*
var publicFiles embed.FS
// Server wraps the HTTP server and application state.
type Server struct {
config Config
http *http.Server
mux *http.ServeMux
appConfig *Config // mutable config reference for hot-reload
}
// NewServer creates and configures the HTTP server.
func NewServer(cfg Config) (*Server, error) {
s := &Server{
config: cfg,
appConfig: &cfg,
mux: http.NewServeMux(),
}
s.registerRoutes()
s.http = &http.Server{
Addr: cfg.Server.BindHost + cfg.Server.Port,
Handler: s.middleware(s.mux),
}
return s, nil
}
// ListenAndServe starts the HTTP server.
func (s *Server) ListenAndServe() error {
log.Printf("HTTP server listening on %s", s.http.Addr)
return s.http.ListenAndServe()
}
// Close shuts down the HTTP server.
func (s *Server) Close() {
if s.http != nil {
s.http.Close()
}
}
// registerRoutes sets up all API and static file routes.
func (s *Server) registerRoutes() {
// API endpoints.
s.mux.HandleFunc("/api/cameras", s.handleCameras)
s.mux.HandleFunc("/api/cameras/", s.handleCameraByID)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/status", s.handleStatus)
s.mux.HandleFunc("/api/recordings", s.handleRecordings)
// Static file server for embedded SPA.
publicFS, _ := fs.Sub(publicFiles, "public")
s.mux.Handle("/", http.FileServer(http.FS(publicFS)))
}
// middleware wraps handlers with CORS and logging.
func (s *Server) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// API logging.
if strings.HasPrefix(r.URL.Path, "/api/") {
log.Printf("%s %s", r.Method, r.URL.Path)
}
next.ServeHTTP(w, r)
})
}
// jsonResponse writes a JSON response with the given status code.
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(resp)
}

89
setup.sh Executable file
View file

@ -0,0 +1,89 @@
#!/bin/bash
# NextNVR v0.1.0 — Deployment Script
# Clones the repository and deploys to /opt/nextnvr
set -e
GIT_URL="https://git.lohmar.co.uk/cclohmar/NextNVR.git"
DEPLOY_DIR="/opt/nextnvr"
SERVICE_NAME="nextnvr"
GO_BIN="/home/master/.local/go/bin/go"
FFMPEG_BIN="ffmpeg"
echo "=== NextNVR Deployment ==="
echo "Target: ${DEPLOY_DIR}"
echo "Git: ${GIT_URL}"
# 1. Install system dependencies if missing.
if ! command -v ffmpeg &>/dev/null; then
echo "Installing ffmpeg..."
sudo apt-get update -qq
sudo apt-get install -y -qq ffmpeg
fi
# 2. Clone or pull repository.
if [ -d "${DEPLOY_DIR}" ]; then
echo "Repository exists, pulling latest..."
cd "${DEPLOY_DIR}"
git pull origin main
else
echo "Cloning repository..."
sudo mkdir -p "${DEPLOY_DIR}"
sudo chown master:master "${DEPLOY_DIR}"
git clone "${GIT_URL}" "${DEPLOY_DIR}"
cd "${DEPLOY_DIR}"
fi
# 3. Build the Go binary with embedded static files.
echo "Building NextNVR binary..."
export PATH=$HOME/.local/go/bin:$PATH
export GOPATH=$HOME/go
go mod tidy
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(cat VERSION) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o nextnvr .
# 4. Copy config if not present.
if [ ! -f "${DEPLOY_DIR}/config.yaml" ]; then
cp config.yaml "${DEPLOY_DIR}/config.yaml"
echo "Default config.yaml created."
fi
# 5. Set permissions.
sudo chown -R master:master "${DEPLOY_DIR}"
chmod +x "${DEPLOY_DIR}/nextnvr"
# 6. Install systemd service.
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
if [ ! -f "${SERVICE_FILE}" ]; then
echo "Creating systemd service..."
sudo tee "${SERVICE_FILE}" > /dev/null <<EOF
[Unit]
Description=NextNVR — IP Camera Recorder
After=network.target
[Service]
Type=simple
User=master
WorkingDirectory=${DEPLOY_DIR}
ExecStart=${DEPLOY_DIR}/nextnvr --config ${DEPLOY_DIR}/config.yaml
Restart=always
RestartSec=10
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable "${SERVICE_NAME}"
fi
# 7. Restart service.
sudo systemctl restart "${SERVICE_NAME}"
sleep 2
sudo systemctl status "${SERVICE_NAME}" --no-pager
echo ""
echo "=== Deployment Complete ==="
echo "NextNVR running on http://127.0.0.1:8080"
echo "Service: sudo systemctl [start|stop|restart|status] ${SERVICE_NAME}"
echo "Logs: sudo journalctl -u ${SERVICE_NAME} -f"
echo "Config: ${DEPLOY_DIR}/config.yaml"