NextNVR/api.go

229 lines
6.6 KiB
Go

// 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
}