diff --git a/.gitignore b/.gitignore index d4d5652..964f705 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Binaries bin/ +strix *.exe *.exe~ *.dll diff --git a/internal/api/routes.go b/internal/api/routes.go index 3124580..99afb15 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -2,7 +2,6 @@ package api import ( "net/http" - "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -100,7 +99,7 @@ func (s *Server) setupRoutes() { s.router.Use(middleware.RealIP) s.router.Use(middleware.Logger) s.router.Use(middleware.Recoverer) - s.router.Use(middleware.Timeout(60 * time.Second)) + // Note: No global timeout middleware - endpoints use context-based timeouts from request parameters // CORS middleware s.router.Use(func(next http.Handler) http.Handler { diff --git a/internal/camera/database/loader.go b/internal/camera/database/loader.go index 04f1b1e..5c37821 100644 --- a/internal/camera/database/loader.go +++ b/internal/camera/database/loader.go @@ -78,6 +78,10 @@ func (l *Loader) ListBrands() ([]string, error) { var brands []string for _, file := range files { if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") { + // Skip index files + if file.Name() == "index.json" || file.Name() == "indexa.json" { + continue + } brandID := strings.TrimSuffix(file.Name(), ".json") brands = append(brands, brandID) } @@ -157,6 +161,11 @@ func (l *Loader) StreamingSearch(searchFunc func(*models.Camera) bool) ([]*model continue } + // Skip index.json as it contains brand list, not camera data + if file.Name() == "index.json" || file.Name() == "indexa.json" { + continue + } + filePath := filepath.Join(l.brandsPath, file.Name()) camera, err := l.loadCameraFromFile(filePath) if err != nil { diff --git a/internal/camera/discovery/onvif_simple.go b/internal/camera/discovery/onvif_simple.go index 8432345..32f823b 100644 --- a/internal/camera/discovery/onvif_simple.go +++ b/internal/camera/discovery/onvif_simple.go @@ -2,10 +2,16 @@ package discovery import ( "context" + "encoding/xml" "fmt" + "io" "net/url" "strings" + "time" + "github.com/IOTechSystems/onvif" + "github.com/IOTechSystems/onvif/media" + xsdonvif "github.com/IOTechSystems/onvif/xsd/onvif" "github.com/strix-project/strix/internal/models" ) @@ -28,12 +34,159 @@ func (o *ONVIFDiscovery) DiscoverStreamsForIP(ctx context.Context, ip, username, ip = ip[:idx] } - // Return common RTSP streams as we can't use complex ONVIF due to API changes - streams := o.getCommonRTSPStreams(ip, username, password) + var allStreams []models.DiscoveredStream - o.logger.Debug("generated common RTSP streams", "count", len(streams)) + // Try real ONVIF discovery first + onvifStreams := o.discoverViaONVIF(ctx, ip, username, password) + allStreams = append(allStreams, onvifStreams...) - return streams, nil + // Add common RTSP streams + commonStreams := o.getCommonRTSPStreams(ip, username, password) + allStreams = append(allStreams, commonStreams...) + + o.logger.Debug("collected streams", "onvif", len(onvifStreams), "common", len(commonStreams), "total", len(allStreams)) + + return allStreams, nil +} + +// discoverViaONVIF performs real ONVIF discovery +func (o *ONVIFDiscovery) discoverViaONVIF(ctx context.Context, ip, username, password string) []models.DiscoveredStream { + var streams []models.DiscoveredStream + + // Try standard ONVIF ports + ports := []int{80, 8080, 8000} + + for _, port := range ports { + // Create timeout context for ONVIF connection + onvifCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + xaddr := fmt.Sprintf("%s:%d", ip, port) + + o.logger.Debug("trying ONVIF connection", "xaddr", xaddr) + + // Create ONVIF device + dev, err := onvif.NewDevice(onvif.DeviceParams{ + Xaddr: xaddr, + Username: username, + Password: password, + }) + if err != nil { + o.logger.Debug("ONVIF device creation failed", "xaddr", xaddr, "error", err.Error()) + continue + } + + // Try to get profiles with context + profileStreams := o.getProfileStreams(onvifCtx, dev, ip) + if len(profileStreams) > 0 { + streams = append(streams, profileStreams...) + o.logger.Debug("ONVIF discovery successful", "xaddr", xaddr, "profiles", len(profileStreams)) + break // Found working port, stop trying + } + } + + return streams +} + +// getProfileStreams gets stream URIs from media profiles +func (o *ONVIFDiscovery) getProfileStreams(ctx context.Context, dev *onvif.Device, ip string) []models.DiscoveredStream { + var streams []models.DiscoveredStream + + // Get media profiles + getProfilesReq := media.GetProfiles{} + profilesResp, err := dev.CallMethod(getProfilesReq) + if err != nil { + o.logger.Debug("failed to get ONVIF profiles", "error", err.Error()) + return streams + } + defer profilesResp.Body.Close() + + // Read and parse XML response + body, err := io.ReadAll(profilesResp.Body) + if err != nil { + o.logger.Debug("failed to read profiles response", "error", err.Error()) + return streams + } + + // Parse SOAP envelope + var envelope struct { + XMLName xml.Name `xml:"Envelope"` + Body struct { + GetProfilesResponse media.GetProfilesResponse `xml:"GetProfilesResponse"` + } `xml:"Body"` + } + + if err := xml.Unmarshal(body, &envelope); err != nil { + o.logger.Debug("failed to parse profiles response", "error", err.Error()) + return streams + } + + // Get stream URI for each profile + for _, profile := range envelope.Body.GetProfilesResponse.Profiles { + streamURI := o.getStreamURI(dev, string(profile.Token)) + if streamURI != "" { + streams = append(streams, models.DiscoveredStream{ + URL: streamURI, + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + Working: false, // Will be tested later + Metadata: map[string]interface{}{ + "source": "onvif", + "profile_token": string(profile.Token), + "profile_name": string(profile.Name), + }, + }) + } + } + + return streams +} + +// getStreamURI retrieves stream URI for a profile +func (o *ONVIFDiscovery) getStreamURI(dev *onvif.Device, profileToken string) string { + stream := xsdonvif.StreamType("RTP-Unicast") + protocol := xsdonvif.TransportProtocol("RTSP") + token := xsdonvif.ReferenceToken(profileToken) + + getStreamURIReq := media.GetStreamUri{ + ProfileToken: &token, + StreamSetup: &xsdonvif.StreamSetup{ + Stream: &stream, + Transport: &xsdonvif.Transport{ + Protocol: &protocol, + }, + }, + } + + resp, err := dev.CallMethod(getStreamURIReq) + if err != nil { + o.logger.Debug("failed to get stream URI", "profile", profileToken, "error", err.Error()) + return "" + } + defer resp.Body.Close() + + // Read and parse XML response + body, err := io.ReadAll(resp.Body) + if err != nil { + o.logger.Debug("failed to read stream URI response", "error", err.Error()) + return "" + } + + // Parse SOAP envelope + var envelope struct { + XMLName xml.Name `xml:"Envelope"` + Body struct { + GetStreamUriResponse media.GetStreamUriResponse `xml:"GetStreamUriResponse"` + } `xml:"Body"` + } + + if err := xml.Unmarshal(body, &envelope); err != nil { + o.logger.Debug("failed to parse stream URI response", "error", err.Error()) + return "" + } + + return string(envelope.Body.GetStreamUriResponse.MediaUri.Uri) } // getCommonRTSPStreams returns common RTSP stream URLs diff --git a/internal/camera/stream/tester.go b/internal/camera/stream/tester.go index b24967a..94ecb0f 100644 --- a/internal/camera/stream/tester.go +++ b/internal/camera/stream/tester.go @@ -23,7 +23,7 @@ type Tester struct { func NewTester(ffprobeTimeout time.Duration, logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *Tester { return &Tester{ httpClient: &http.Client{ - Timeout: 10 * time.Second, + Timeout: 30 * time.Second, }, ffprobeTimeout: ffprobeTimeout, logger: logger, @@ -265,6 +265,11 @@ func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password str // Try to probe with ffprobe for more details t.probeHTTPVideo(ctx, streamURL, username, password, result) + case strings.Contains(contentType, "text/html"), strings.Contains(contentType, "text/plain"): + // Ignore web interfaces and plain text responses + result.Working = false + result.Error = "web interface, not a video stream" + default: result.Type = "HTTP_UNKNOWN" result.Working = true // Assume it works if we got 200 OK diff --git a/internal/config/config.go b/internal/config/config.go index e15b4d8..8a92b6c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "log/slog" "os" + "path/filepath" "time" ) @@ -41,6 +42,10 @@ type ScannerConfig struct { FFProbeTimeout time.Duration RetryAttempts int RetryDelay time.Duration + // Validation settings + StrictValidation bool // Enable strict validation mode + MinImageSize int // Minimum bytes for valid image (JPEG/PNG) + MinVideoStreams int // Minimum video streams required } // LoggerConfig contains logging settings @@ -51,6 +56,8 @@ type LoggerConfig struct { // Load returns configuration with defaults func Load() *Config { + dataPath := getEnv("STRIX_DATA_PATH", "./data") + return &Config{ Server: ServerConfig{ Host: getEnv("STRIX_HOST", "0.0.0.0"), @@ -59,10 +66,10 @@ func Load() *Config { WriteTimeout: 30 * time.Second, }, Database: DatabaseConfig{ - DataPath: getEnv("STRIX_DATA_PATH", "/home/dev/Strix/data"), - BrandsPath: "/home/dev/Strix/data/brands", - PatternsPath: "/home/dev/Strix/data/popular_stream_patterns.json", - ParametersPath: "/home/dev/Strix/data/query_parameters.json", + DataPath: dataPath, + BrandsPath: filepath.Join(dataPath, "brands"), + PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"), + ParametersPath: filepath.Join(dataPath, "query_parameters.json"), CacheEnabled: true, CacheTTL: 5 * time.Minute, }, @@ -71,9 +78,13 @@ func Load() *Config { MaxStreams: 10, ModelSearchLimit: 6, WorkerPoolSize: 20, - FFProbeTimeout: 5 * time.Second, + FFProbeTimeout: 30 * time.Second, RetryAttempts: 2, RetryDelay: 500 * time.Millisecond, + // Strict validation enabled by default + StrictValidation: true, + MinImageSize: 5120, // 5KB minimum for valid images + MinVideoStreams: 1, // At least 1 video stream required }, Logger: LoggerConfig{ Level: getEnv("STRIX_LOG_LEVEL", "info"),