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 <