// NextNVR v0.1.0 — API handlers // REST endpoints for cameras, configuration, ONVIF discovery, and status. package main import ( "encoding/json" "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"}) } } // handleScan triggers ONVIF network discovery. // 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"` } 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() // Skip in-progress .part files. isPart := strings.HasSuffix(strings.ToLower(name), ".part.mp4") isMP4 := strings.HasSuffix(strings.ToLower(name), ".mp4") if !isMP4 && !isPart { continue } // Extract timestamp from filename: YYYY-MM-DD-HH-MM.mp4 base := strings.TrimSuffix(strings.TrimSuffix(name, ".mp4"), ".part") fileTime, parseErr := time.Parse("2006-01-02-15-04", base) info, statErr := entry.Info() if statErr != nil { continue } clip := Clip{ Name: name, Path: camDir + "/" + name, Size: info.Size(), Live: isPart, } if parseErr == nil { clip.Time = fileTime.Format("15:04") // Apply date filter. if !fromTime.IsZero() && fileTime.Before(fromTime) { continue } if !toTime.IsZero() && fileTime.After(toTime) { continue } } clips = append([]Clip{clip}, clips...) // prepend = 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 }