// Package api provides HTTP handlers for camera operations. package api import ( "database/sql" "encoding/json" "log" "net/http" "strings" "github.com/cubecraft/remoterig/internal/db" "github.com/cubecraft/remoterig/pkg/models" "github.com/go-chi/chi/v5" ) // ListCameras returns a handler for GET /cameras. // Returns all cameras with their latest status from status_logs. func ListCameras(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Query: join cameras with their most recent status_logs row rows, err := database.QueryContext(ctx, ` SELECT c.camera_id, c.friendly_name, s.battery_pct, s.video_remaining_sec, COALESCE(s.recording_state, 0), s.mode, s.resolution, s.fps, COALESCE(s.online, 0), COALESCE(s.recorded_at, c.created_at) FROM cameras c LEFT JOIN ( SELECT camera_id, battery_pct, video_remaining_sec, recording_state, mode, resolution, fps, online, recorded_at, ROW_NUMBER() OVER (PARTITION BY camera_id ORDER BY recorded_at DESC) as rn FROM status_logs ) s ON c.camera_id = s.camera_id AND s.rn = 1 ORDER BY c.camera_id `) if err != nil { log.Printf("Error querying cameras: %v", err) respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } defer rows.Close() var statuses []models.CameraStatus for rows.Next() { var sl models.StatusLog var c models.Camera if err := rows.Scan( &c.CameraID, &c.FriendlyName, &sl.BatteryPct, &sl.VideoRemainingSec, &sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS, &sl.Online, &sl.RecordedAt, ); err != nil { log.Printf("Error scanning camera row: %v", err) continue } statuses = append(statuses, models.NewCameraStatus(c, sl)) } if err := rows.Err(); err != nil { log.Printf("Error iterating camera rows: %v", err) respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } if statuses == nil { statuses = []models.CameraStatus{} } respondJSON(w, http.StatusOK, statuses) } } // RegisterCamera returns a handler for POST /cameras. func RegisterCamera(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { CameraID string `json:"camera_id"` FriendlyName string `json:"friendly_name"` MacAddress *string `json:"mac_address,omitempty"` } if !decodeJSONBody(w, r, &req) { return } if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) { return } _, err := database.ExecContext(r.Context(), ` INSERT INTO cameras (camera_id, friendly_name, mac_address) VALUES (?, ?, ?) `, req.CameraID, req.FriendlyName, req.MacAddress) if err != nil { if isUniqueConstraintErr(err) { respondError(w, http.StatusConflict, "camera already registered", err.Error()) return } log.Printf("Error registering camera: %v", err) respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } log.Printf("Registered camera %s (%s)", req.CameraID, req.FriendlyName) resp := map[string]interface{}{ "camera_id": req.CameraID, "friendly_name": req.FriendlyName, } if req.MacAddress != nil { resp["mac_address"] = *req.MacAddress } respondJSON(w, http.StatusCreated, resp) } } // GetCameraDetail returns a handler for GET /cameras/{id}. func GetCameraDetail(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cameraID := chi.URLParam(r, "id") if !validateCameraID(w, cameraID) { return } // Get camera info var c models.Camera err := database.QueryRowContext(r.Context(), ` SELECT camera_id, friendly_name, mac_address, created_at, updated_at FROM cameras WHERE camera_id = ? `, cameraID).Scan( &c.CameraID, &c.FriendlyName, &c.MacAddress, &c.CreatedAt, &c.UpdatedAt, ) if err == sql.ErrNoRows { respondError(w, http.StatusNotFound, "camera not found", err.Error()) return } if err != nil { log.Printf("Error querying camera: %v", err) respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } // Get latest status var sl models.StatusLog err = database.QueryRowContext(r.Context(), ` SELECT id, camera_id, recorded_at, battery_pct, video_remaining_sec, recording_state, mode, resolution, fps, online, raw_battery_pct FROM status_logs WHERE camera_id = ? ORDER BY recorded_at DESC LIMIT 1 `, cameraID).Scan( &sl.ID, &sl.CameraID, &sl.RecordedAt, &sl.BatteryPct, &sl.VideoRemainingSec, &sl.RecordingState, &sl.Mode, &sl.Resolution, &sl.FPS, &sl.Online, &sl.RawBatteryPct, ) if err != nil && err != sql.ErrNoRows { log.Printf("Error querying latest status: %v", err) respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } // Get 24h history historyRows, err := database.QueryContext(r.Context(), ` SELECT id, camera_id, recorded_at, battery_pct, video_remaining_sec, recording_state, mode, resolution, fps, online, raw_battery_pct FROM status_logs WHERE camera_id = ? AND recorded_at >= datetime('now', '-24 hours') ORDER BY recorded_at DESC LIMIT 100 `, cameraID) if err != nil { log.Printf("Error querying history: %v", err) respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } defer historyRows.Close() var history []models.StatusLog for historyRows.Next() { var h models.StatusLog if err := historyRows.Scan( &h.ID, &h.CameraID, &h.RecordedAt, &h.BatteryPct, &h.VideoRemainingSec, &h.RecordingState, &h.Mode, &h.Resolution, &h.FPS, &h.Online, &h.RawBatteryPct, ); err != nil { continue } history = append(history, h) } if history == nil { history = []models.StatusLog{} } respondJSON(w, http.StatusOK, map[string]interface{}{ "camera": c, "last_status": sl, "history": history, }) } } // isUniqueConstraintErr checks if the error is a SQLite UNIQUE constraint violation. func isUniqueConstraintErr(err error) bool { if err == nil { return false } return strings.Contains(err.Error(), "UNIQUE constraint failed") } // respondJSON writes a JSON response with the given status code. func respondJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) }