229 lines
6.6 KiB
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
|
|
}
|