NextNVR/api.go

434 lines
12 KiB
Go

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