NextNVR/api.go

336 lines
9.3 KiB
Go

// NextNVR v0.1.0 — API handlers
// REST endpoints for cameras, configuration, ONVIF discovery, and status.
package main
import (
"encoding/json"
"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
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}&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
}