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 => ` +🎥 Welcome to NextNVR
+No cameras configured yet. Scan your network to get started.
+ +