From 84bf6d892fdc8fa7221718327cf6e29ebeade6a8 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Wed, 5 Aug 2026 09:15:43 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20Milestone=201=20&=202=20=E2=80=94=20co?= =?UTF-8?q?re=20setup,=20recorder,=20cleaner,=20web=20UI,=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + CHANGELOG.md | 26 +++++ VERSION | 1 + api.go | 229 ++++++++++++++++++++++++++++++++++++++ cleaner.go | 130 ++++++++++++++++++++++ config.go | 132 ++++++++++++++++++++++ config.yaml | 113 +++++++++++++++++++ go.mod | 5 + go.sum | 4 + go2rtc.go | 143 ++++++++++++++++++++++++ main.go | 112 +++++++++++++++++++ public/app.js | 278 ++++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 65 +++++++++++ public/style.css | 180 ++++++++++++++++++++++++++++++ recorder.go | 141 +++++++++++++++++++++++ server.go | 97 ++++++++++++++++ setup.sh | 89 +++++++++++++++ 17 files changed, 1746 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 VERSION create mode 100644 api.go create mode 100644 cleaner.go create mode 100644 config.go create mode 100644 config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 go2rtc.go create mode 100644 main.go create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 recorder.go create mode 100644 server.go create mode 100755 setup.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8e5d5f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +nextnvr diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c1a8752 --- /dev/null +++ b/CHANGELOG.md @@ -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.201–209, skip 204) +- go2rtc child process manager with auto-restart +- Deployment script (`setup.sh`) with systemd service generation diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/api.go b/api.go new file mode 100644 index 0000000..f26a326 --- /dev/null +++ b/api.go @@ -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 +} diff --git a/cleaner.go b/cleaner.go new file mode 100644 index 0000000..f3633dc --- /dev/null +++ b/cleaner.go @@ -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" +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..87140a9 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..db65057 --- /dev/null +++ b/config.yaml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..60ace43 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/cclohmar/NextNVR + +go 1.24.5 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/go2rtc.go b/go2rtc.go new file mode 100644 index 0000000..2e9b468 --- /dev/null +++ b/go2rtc.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4c6119b --- /dev/null +++ b/main.go @@ -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() + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..8365a3b --- /dev/null +++ b/public/app.js @@ -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 = ` + + ${cam.name} + `; + tile.addEventListener('click', () => openFocus(cam)); + } else { + tile.innerHTML = 'No Camera'; + } + 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 = ''; + cameras.forEach(c => { + sel.innerHTML += ``; + }); +} +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 = '

No recordings found.

'; return; } + grid.innerHTML = clips.map(c => ` +
+
🎬 ${c.name}
+
${c.time}
+
${formatSize(c.size)}
+
+ `).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 = ` +
+

🎥 Welcome to NextNVR

+

No cameras configured yet. Scan your network to get started.

+ +
`; + return; + } + container.innerHTML = cameras.map(c => ` +
+

📷 ${c.name || 'Unnamed'} ${c.ip}

+ + + + + + +
+ + +
+
+ `).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.201–209...'; + 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,'&').replace(/"/g,'"'); } +function escHtml(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); } +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]; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4dbb84e --- /dev/null +++ b/public/index.html @@ -0,0 +1,65 @@ + + + + + + NextNVR — Casa Alba Mindelo + + + +
+

🎥 NextNVR

+ + +
+ +
+ +
+
+ +
+ + +
+
+ + + +
+
+ +
+ + +
+
+ + + +
+
+ +
+
+ + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..a029225 --- /dev/null +++ b/public/style.css @@ -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; } +} diff --git a/recorder.go b/recorder.go new file mode 100644 index 0000000..3d71086 --- /dev/null +++ b/recorder.go @@ -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) + } +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..c2414c8 --- /dev/null +++ b/server.go @@ -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) +} diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..3c09ce9 --- /dev/null +++ b/setup.sh @@ -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 <