// NextNVR v0.1.0 — API handlers // REST endpoints for cameras, configuration, ONVIF discovery, and status. package main import ( "encoding/json" "io" "net" "net/http" "os" "strings" "time" ) // 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"}) } } // handleStream proxies MJPEG streams from go2rtc to avoid cross-origin issues. // GET /stream/{cam_id}?type=sub (default: sub stream via go2rtc) func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { camID := strings.TrimPrefix(r.URL.Path, "/stream/") if camID == "" { http.Error(w, "camera ID required", http.StatusBadRequest) return } streamType := r.URL.Query().Get("type") if streamType == "" { streamType = "sub" } go2rtcURL := "http://127.0.0.1:1984/api/stream.mjpeg?src=" + camID + "_" + streamType resp, err := http.Get(go2rtcURL) if err != nil { http.Error(w, "stream unavailable", http.StatusServiceUnavailable) return } defer resp.Body.Close() // Copy headers from go2rtc. for k, v := range resp.Header { for _, vv := range v { w.Header().Add(k, vv) } } w.WriteHeader(resp.StatusCode) // Stream the MJPEG data directly to the client. io.Copy(w, resp.Body) } // POST /api/scan // Body: {"method":"range","from":"192.168.1.200","to":"192.168.1.210","username":"admin","password":"..."} 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 } var req ScanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // Fall back to legacy stub scan if no body provided. s.legacyScan(w) return } if req.From == "" || req.To == "" { jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "from and to IP addresses required"}) return } fromIP := net.ParseIP(req.From) toIP := net.ParseIP(req.To) if fromIP == nil || toIP == nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid IP address"}) return } results := make([]ONVIFDiscovery, 0) // Inclusive scan from fromIP to toIP. ip := make(net.IP, len(fromIP)) copy(ip, fromIP) for { d := probeDevice(ip.String(), req.Username, req.Password) results = append(results, d) if ip.Equal(toIP) { break } ip = nextIP(ip) } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results}) } // legacyScan is the old stub handler for backward compatibility. func (s *Server) legacyScan(w http.ResponseWriter) { 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, Found: false, RTSPMain: "rtsp://" + ipStr + ":554/Streaming/Channels/101", RTSPSub: "rtsp://" + ipStr + ":554/Streaming/Channels/102", }) } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results}) } // nextIP returns the next IP address in sequence. func nextIP(ip net.IP) net.IP { next := make(net.IP, len(ip)) copy(next, ip) for i := len(next) - 1; i >= 0; i-- { next[i]++ if next[i] != 0 { break } } return next } // 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}&preset={today|yesterday|week}&from={date}&to={date} // // The recordings directory structure is flat per camera: // // /mnt/recordings/{cam-name}/{YYYY-MM-DD-HH-MM}.mp4 // // In-progress recordings use .part.mp4 suffix and are excluded from results. 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") preset := r.URL.Query().Get("preset") fromStr := r.URL.Query().Get("from") toStr := r.URL.Query().Get("to") type Clip struct { Name string `json:"name"` Path string `json:"path"` Size int64 `json:"size"` Time string `json:"time"` Live bool `json:"live"` Snap string `json:"snap"` } clips := make([]Clip, 0) if camID == "" { jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips}) return } // Resolve camera name for directory lookup. cam, _ := s.findCamera(camID) camDir := camID if cam != nil && cam.Name != "" { camDir = cam.Name } // Determine date range from preset or custom range. var fromTime, toTime time.Time now := time.Now() switch preset { case "today": fromTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) toTime = now case "yesterday": yesterday := now.AddDate(0, 0, -1) fromTime = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, now.Location()) toTime = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 59, 59, 0, now.Location()) case "week": fromTime = now.AddDate(0, 0, -7) toTime = now default: // Parse custom date range. if fromStr != "" { fromTime, _ = time.Parse("2006-01-02", fromStr) } if toStr != "" { toTime, _ = time.Parse("2006-01-02", toStr) toTime = toTime.Add(24*time.Hour - time.Second) // end of day } if fromStr == "" { fromTime = now.AddDate(0, 0, -1) // default: last 24h } if toStr == "" { toTime = now } } // Scan the flat camera directory. recDir := s.appConfig.Storage.RecordingsPath scanDir := recDir + "/" + camDir entries, err := os.ReadDir(scanDir) if err != nil { // Directory doesn't exist yet — no recordings. jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips}) return } for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() // Only process rec_*.mp4 files (not snap_*.jpg). if !strings.HasPrefix(name, "rec_") || !strings.HasSuffix(strings.ToLower(name), ".mp4") { continue } info, statErr := entry.Info() if statErr != nil { continue } clip := Clip{ Name: name, Path: camDir + "/" + name, Size: info.Size(), } // Matching snapshot: replace rec_ with snap_, .mp4 with .jpg snapName := strings.Replace(name, "rec_", "snap_", 1) snapName = strings.Replace(snapName, ".mp4", ".jpg", 1) snapPath := camDir + "/" + snapName if _, err := os.Stat(recDir + "/" + snapName); err == nil { clip.Snap = snapPath } // Extract time from filename: rec_YYYY-MM-DD-HH-MM-SS.mp4 timeStr := strings.TrimPrefix(name, "rec_") timeStr = strings.TrimSuffix(timeStr, ".mp4") timeStr = strings.TrimSuffix(timeStr, ".part") if t, err := time.Parse("2006-01-02-15-04-05", timeStr); err == nil { clip.Time = t.Format("15:04") if !fromTime.IsZero() && t.Before(fromTime) { continue } if !toTime.IsZero() && t.After(toTime) { continue } } clips = append([]Clip{clip}, clips...) // newest first } 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 } // 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" }