Refactor authentication system: centralize credential handling in Builder

Major architectural improvement to eliminate duplicate credential embedding
and simplify authentication logic across the codebase.

Changes:
- Builder now generates all URL variants with proper authentication upfront
  * RTSP: generates URLs with embedded credentials and without (for open cameras)
  * HTTP/JPEG/MJPEG: generates variants with query params and for Basic Auth
- Tester simplified to only test ready-to-use URLs without modification
  * Removed auth chain logic and multiple authentication method attempts
  * Removed AuthMethod enum and related complexity
  * Credentials automatically extracted from URLs when needed
- Scanner cleaned up by removing embedCredentialsInURL function
  * All TestStream calls now use single URL parameter
  * Removed AuthMethod from DiscoveredStream model

Benefits:
- Eliminates bug where credentials were added up to 3 times
- Centralizes all URL generation logic in one place (Builder)
- Cleaner, more maintainable code with clear separation of concerns
- Reduces complexity by ~200 lines of code
- All authentication scenarios still fully supported
This commit is contained in:
eduard256
2025-11-07 19:08:05 +03:00
parent 292d5f8049
commit 88e76eadb5
8 changed files with 351 additions and 397 deletions
+54
View File
@@ -0,0 +1,54 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Run tests
run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: Build
run: make build
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
+32
View File
@@ -0,0 +1,32 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+112
View File
@@ -0,0 +1,112 @@
# GoReleaser configuration for Strix
version: 2
before:
hooks:
- go mod tidy
- go mod download
builds:
- id: strix
main: ./cmd/strix/main.go
binary: strix
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
- arm
goarm:
- "7"
ignore:
- goos: windows
goarch: arm
- goos: darwin
goarch: arm
ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.BuildDate={{.Date}}
- -X main.GitCommit={{.ShortCommit}}
archives:
- id: strix-archive
format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
files:
- README.md
- LICENSE
- webui/**/*
- cameras/**/*
checksum:
name_template: 'checksums.txt'
algorithm: sha256
changelog:
sort: asc
use: github
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
- typo
groups:
- title: '🚀 Features'
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
order: 0
- title: '🐛 Bug Fixes'
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
order: 1
- title: '📝 Documentation'
regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$'
order: 2
- title: '🔧 Other'
order: 999
release:
github:
owner: eduard256
name: Strix
draft: false
prerelease: auto
name_template: "v{{.Version}}"
header: |
## 🦉 Strix v{{.Version}}
Smart IP Camera Stream Discovery System
### Installation
Download the appropriate binary for your platform below and extract it.
### Usage
```bash
./strix
```
Then open http://localhost:4567 in your browser.
footer: |
**Full Changelog**: https://github.com/eduard256/Strix/compare/{{ .PreviousTag }}...{{ .Tag }}
snapshot:
name_template: "{{ incpatch .Version }}-next"
dist: dist
+33
View File
@@ -0,0 +1,33 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2025-11-06
### Added
- 🦉 Initial release of Strix
- 🌐 Web-based user interface for camera stream discovery
- 🔍 Automatic RTSP stream discovery for IP cameras
- 📹 Support for multiple camera manufacturers
- 🎯 ONVIF device discovery and PTZ endpoint detection
- 🔐 Credential embedding in stream URLs
- 📊 Camera model database with autocomplete search
- 🎨 Modern, responsive UI with purple owl logo
- ⚙️ Configuration export for Go2RTC and Frigate
- 🔄 Dual-stream support with optional sub-stream selection
- 📡 Server-Sent Events (SSE) for real-time discovery progress
- 🚀 RESTful API for camera search and stream discovery
- 📦 Cross-platform support (Linux, Windows, macOS)
- 🏗️ Built with Go for high performance
### Features
- **Web Interface**: Clean, intuitive UI for camera configuration
- **Stream Discovery**: Automatically finds working RTSP streams
- **ONVIF Support**: Discovers ONVIF devices and PTZ capabilities
- **Multi-Platform**: Binaries for Linux (amd64, arm64, arm/v7), Windows, and macOS
- **Easy Integration**: Export configs for popular NVR systems
[0.1.0]: https://github.com/eduard256/Strix/releases/tag/v0.1.0
+4 -51
View File
@@ -173,21 +173,17 @@ func (s *Scanner) isDirectStreamURL(target string) bool {
func (s *Scanner) scanDirectStream(ctx context.Context, req models.StreamDiscoveryRequest, streamWriter *sse.StreamWriter, result *ScanResult) (*ScanResult, error) {
s.logger.Debug("testing direct stream URL", "url", req.Target)
testResult := s.tester.TestStream(ctx, req.Target, req.Username, req.Password)
testResult := s.tester.TestStream(ctx, req.Target)
result.TotalTested = 1
if testResult.Working {
result.TotalFound = 1
// Embed credentials in URL for basic_auth and combined methods
finalURL := s.embedCredentialsInURL(testResult.URL, req.Username, req.Password, string(testResult.AuthMethod))
discoveredStream := models.DiscoveredStream{
URL: finalURL,
URL: testResult.URL,
Type: testResult.Type,
Protocol: testResult.Protocol,
Working: true,
AuthMethod: string(testResult.AuthMethod),
Resolution: testResult.Resolution,
Codec: testResult.Codec,
FPS: testResult.FPS,
@@ -235,45 +231,6 @@ func (s *Scanner) extractIP(target string) string {
return target
}
// embedCredentialsInURL embeds username and password in URL for basic_auth and combined methods
func (s *Scanner) embedCredentialsInURL(streamURL, username, password, authMethod string) string {
// Only apply for basic_auth and combined methods
if authMethod != "basic_auth" && authMethod != "combined" {
return streamURL
}
// Check if credentials are provided
if username == "" || password == "" {
return streamURL
}
// Parse URL
u, err := url.Parse(streamURL)
if err != nil {
s.logger.Debug("failed to parse URL for credential embedding",
"url", streamURL,
"error", err.Error())
return streamURL
}
// Check if credentials already exist in URL
if u.User != nil {
s.logger.Debug("credentials already exist in URL, skipping embedding",
"url", streamURL)
return streamURL
}
// Embed credentials
u.User = url.UserPassword(username, password)
embeddedURL := u.String()
s.logger.Debug("credentials embedded in URL",
"original_url", streamURL,
"embedded_url", embeddedURL,
"auth_method", authMethod)
return embeddedURL
}
// collectStreams collects all streams to test with their metadata
func (s *Scanner) collectStreams(ctx context.Context, req models.StreamDiscoveryRequest, ip string) ([]models.DiscoveredStream, error) {
@@ -529,22 +486,18 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
}
// Test the stream
testResult := s.tester.TestStream(ctx, stream.URL, req.Username, req.Password)
testResult := s.tester.TestStream(ctx, stream.URL)
atomic.AddInt32(&tested, 1)
if testResult.Working {
atomic.AddInt32(&found, 1)
// Embed credentials in URL for basic_auth and combined methods
finalURL := s.embedCredentialsInURL(testResult.URL, req.Username, req.Password, string(testResult.AuthMethod))
discoveredStream := models.DiscoveredStream{
URL: finalURL,
URL: testResult.URL,
Type: testResult.Type,
Protocol: testResult.Protocol,
Port: 0, // Will be extracted from URL if needed
Working: true,
AuthMethod: string(testResult.AuthMethod),
Resolution: testResult.Resolution,
Codec: testResult.Codec,
FPS: testResult.FPS,
+64 -26
View File
@@ -286,42 +286,79 @@ func (b *Builder) cleanURL(fullURL string) string {
// BuildURLsFromEntry generates all possible URLs from a camera entry
func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext) []string {
urlMap := make(map[string]bool)
var urls []string
// Build main URL
mainURL := b.BuildURL(entry, ctx)
b.logger.Debug("BuildURLsFromEntry: main URL built", "url", mainURL, "entry_type", entry.Type)
urls = append(urls, mainURL)
// Helper to add unique URLs
addURL := func(url string) {
if !urlMap[url] {
urls = append(urls, url)
urlMap[url] = true
}
}
switch entry.Protocol {
case "rtsp", "rtsps":
// For RTSP: generate with and without credentials
// 1. With credentials (if provided)
if ctx.Username != "" && ctx.Password != "" {
addURL(b.BuildURL(entry, ctx))
}
// 2. Without credentials (for open cameras)
ctxNoAuth := ctx
ctxNoAuth.Username = ""
ctxNoAuth.Password = ""
addURL(b.BuildURL(entry, ctxNoAuth))
case "http", "https":
// For HTTP/JPEG/MJPEG: generate multiple auth variants
if entry.Type == "JPEG" || entry.Type == "MJPEG" {
// Check if URL has auth placeholders
hasAuthPlaceholders := strings.Contains(entry.URL, "[USERNAME]") ||
strings.Contains(entry.URL, "[PASSWORD]") ||
strings.Contains(entry.URL, "[AUTH]")
if hasAuthPlaceholders {
// 1. URL with credentials in parameters (replaced placeholders)
addURL(b.BuildURL(entry, ctx))
// 2. URL without credentials (for cameras that don't require auth)
ctxNoAuth := ctx
ctxNoAuth.Username = ""
ctxNoAuth.Password = ""
addURL(b.BuildURL(entry, ctxNoAuth))
} else {
// URL without placeholders - will use Basic Auth in headers
// Generate only one URL, auth will be in headers
addURL(b.BuildURL(entry, ctx))
}
} else {
// Other HTTP types - single URL
addURL(b.BuildURL(entry, ctx))
}
default:
// Other protocols - single URL
addURL(b.BuildURL(entry, ctx))
}
// For NVR systems, try multiple channels
if ctx.Channel == 0 && strings.Contains(strings.ToLower(entry.Notes), "channel") {
for ch := 1; ch <= 4; ch++ {
altCtx := ctx
altCtx.Channel = ch
altURL := b.BuildURL(entry, altCtx)
if altURL != mainURL {
urls = append(urls, altURL)
}
}
}
// Try different resolutions for snapshot URLs
if entry.Type == "JPEG" || entry.Type == "MJPEG" {
resolutions := [][2]int{
{640, 480},
{1280, 720},
{1920, 1080},
}
for _, res := range resolutions {
if res[0] != ctx.Width || res[1] != ctx.Height {
altCtx := ctx
altCtx.Width = res[0]
altCtx.Height = res[1]
altURL := b.BuildURL(entry, altCtx)
if altURL != mainURL {
urls = append(urls, altURL)
// Regenerate with different channel
if entry.Protocol == "rtsp" || entry.Protocol == "rtsps" {
if ctx.Username != "" && ctx.Password != "" {
addURL(b.BuildURL(entry, altCtx))
}
altCtx.Username = ""
altCtx.Password = ""
addURL(b.BuildURL(entry, altCtx))
} else {
addURL(b.BuildURL(entry, altCtx))
}
}
}
@@ -329,6 +366,7 @@ func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext)
b.logger.Debug("BuildURLsFromEntry complete",
"entry_url_pattern", entry.URL,
"entry_type", entry.Type,
"entry_protocol", entry.Protocol,
"total_urls_generated", len(urls),
"urls", urls)
+52 -319
View File
@@ -12,23 +12,6 @@ import (
"time"
)
// AuthMethod represents an authentication method
type AuthMethod string
const (
// AuthNone - no authentication
AuthNone AuthMethod = "no_auth"
// AuthBasicHeader - HTTP Basic Auth header only
AuthBasicHeader AuthMethod = "basic_auth"
// AuthQueryParams - credentials in query string parameters
AuthQueryParams AuthMethod = "query_params"
// AuthCombined - both Basic Auth header and query params (ZOSI requirement)
AuthCombined AuthMethod = "combined"
// AuthDigest - HTTP Digest authentication
AuthDigest AuthMethod = "digest"
// AuthURLEmbedded - credentials embedded in URL (rtsp://user:pass@host)
AuthURLEmbedded AuthMethod = "url_embedded"
)
// Tester validates stream URLs
type Tester struct {
@@ -54,7 +37,6 @@ type TestResult struct {
Working bool
Protocol string
Type string
AuthMethod AuthMethod
Resolution string
Codec string
FPS int
@@ -65,278 +47,7 @@ type TestResult struct {
Metadata map[string]interface{}
}
// TestStreamWithAuthChain tests a stream URL with multiple authentication methods using smart fallback chain
func (t *Tester) TestStreamWithAuthChain(ctx context.Context, streamURL, username, password string) TestResult {
startTime := time.Now()
t.logger.Debug("TestStreamWithAuthChain started",
"url", streamURL,
"username", username,
"has_password", password != "")
// Parse URL to determine protocol
u, err := url.Parse(streamURL)
if err != nil {
return TestResult{
URL: streamURL,
Error: fmt.Sprintf("invalid URL: %v", err),
TestTime: time.Since(startTime),
Metadata: make(map[string]interface{}),
}
}
// For RTSP, use the original single-method approach (embedded credentials)
if u.Scheme == "rtsp" || u.Scheme == "rtsps" {
result := t.testWithAuthMethod(ctx, streamURL, username, password, AuthURLEmbedded)
result.TestTime = time.Since(startTime)
return result
}
// For HTTP/HTTPS, use smart auth chain
if u.Scheme == "http" || u.Scheme == "https" {
// Determine if URL already has auth parameters
hasAuthParams := t.hasAuthenticationParams(streamURL)
// Smart priority chain based on URL characteristics
var authChain []AuthMethod
if hasAuthParams {
// URL has auth params - prioritize methods that use them
authChain = []AuthMethod{
AuthCombined, // Try combined first (ZOSI fix!)
AuthQueryParams, // Query params only
AuthBasicHeader, // Basic Auth header only
AuthNone, // No auth (some cameras ignore auth)
}
} else {
// URL doesn't have auth params - standard chain
authChain = []AuthMethod{
AuthNone, // Try without auth first (fast)
AuthBasicHeader, // Most common method
AuthDigest, // Some older cameras
}
}
t.logger.Debug("auth chain determined",
"url", streamURL,
"has_auth_params", hasAuthParams,
"auth_chain", authChain,
"chain_length", len(authChain))
// Try each auth method
for i, method := range authChain {
t.logger.Debug("trying auth method",
"method", method,
"url", streamURL,
"attempt", i+1,
"of", len(authChain))
result := t.testWithAuthMethod(ctx, streamURL, username, password, method)
if result.Working {
// Success! Return immediately
result.TestTime = time.Since(startTime)
t.logger.Debug("auth method SUCCEEDED",
"url", streamURL,
"method", method,
"attempt", i+1,
"of", len(authChain),
"type", result.Type,
"protocol", result.Protocol)
return result
}
// Log failed attempt
t.logger.Debug("auth method FAILED",
"url", streamURL,
"method", method,
"attempt", i+1,
"of", len(authChain),
"error", result.Error)
// Special cases: if we get certain errors, might want to continue or stop
if result.Error != "" {
// If 401 Unauthorized, definitely try next auth method
if strings.Contains(result.Error, "401") || strings.Contains(result.Error, "authentication") {
continue
}
// If connection refused, timeout, or other network errors, no point trying other auth methods
if strings.Contains(result.Error, "connection refused") ||
strings.Contains(result.Error, "timeout") ||
strings.Contains(result.Error, "no route to host") {
result.TestTime = time.Since(startTime)
return result
}
}
}
// All methods failed, return last result
result := TestResult{
URL: streamURL,
Protocol: u.Scheme,
Error: fmt.Sprintf("all authentication methods failed"),
TestTime: time.Since(startTime),
Metadata: make(map[string]interface{}),
}
return result
}
// Unsupported protocol
return TestResult{
URL: streamURL,
Protocol: u.Scheme,
Error: fmt.Sprintf("unsupported protocol: %s", u.Scheme),
TestTime: time.Since(startTime),
Metadata: make(map[string]interface{}),
}
}
// hasAuthenticationParams checks if URL contains auth parameters
func (t *Tester) hasAuthenticationParams(streamURL string) bool {
authParams := []string{
"user=", "username=", "usr=", "loginuse=",
"password=", "pass=", "pwd=", "loginpas=", "passwd=",
}
lowerURL := strings.ToLower(streamURL)
for _, param := range authParams {
if strings.Contains(lowerURL, param) {
return true
}
}
return false
}
// testWithAuthMethod tests a stream with a specific authentication method
func (t *Tester) testWithAuthMethod(ctx context.Context, streamURL, username, password string, method AuthMethod) TestResult {
result := TestResult{
URL: streamURL,
AuthMethod: method,
Metadata: make(map[string]interface{}),
}
// Parse URL
u, err := url.Parse(streamURL)
if err != nil {
result.Error = fmt.Sprintf("invalid URL: %v", err)
return result
}
result.Protocol = u.Scheme
// Handle based on protocol and auth method
switch u.Scheme {
case "rtsp", "rtsps":
t.testRTSPWithAuth(ctx, streamURL, username, password, method, &result)
case "http", "https":
t.testHTTPWithAuth(ctx, streamURL, username, password, method, &result)
default:
result.Error = fmt.Sprintf("unsupported protocol: %s", u.Scheme)
}
return result
}
// testRTSPWithAuth tests RTSP stream with specific auth method
func (t *Tester) testRTSPWithAuth(ctx context.Context, streamURL, username, password string, method AuthMethod, result *TestResult) {
// For RTSP, we only support embedded credentials
if method == AuthURLEmbedded && username != "" && password != "" {
u, _ := url.Parse(streamURL)
u.User = url.UserPassword(username, password)
streamURL = u.String()
}
// Use existing RTSP testing logic
t.testRTSP(ctx, streamURL, username, password, result)
}
// testHTTPWithAuth tests HTTP stream with specific authentication method
func (t *Tester) testHTTPWithAuth(ctx context.Context, streamURL, username, password string, method AuthMethod, result *TestResult) {
// Create request
req, err := http.NewRequestWithContext(ctx, "GET", streamURL, nil)
if err != nil {
result.Error = fmt.Sprintf("failed to create request: %v", err)
return
}
// Apply authentication based on method
switch method {
case AuthNone:
// No authentication - do nothing
case AuthBasicHeader:
// Basic Auth header only
if username != "" && password != "" {
req.SetBasicAuth(username, password)
}
case AuthQueryParams:
// Query params only (already in URL)
// No additional action needed
case AuthCombined:
// Both Basic Auth header AND query params (ZOSI fix!)
if username != "" && password != "" {
req.SetBasicAuth(username, password)
}
// Query params already in URL
case AuthDigest:
// Digest auth requires a challenge-response flow
// For now, we'll try basic auth and let the camera upgrade if needed
if username != "" && password != "" {
req.SetBasicAuth(username, password)
}
}
// Add headers
req.Header.Set("User-Agent", "Strix/1.0")
t.logger.Debug("sending HTTP request",
"url", streamURL,
"method", method,
"has_basic_auth_header", req.Header.Get("Authorization") != "",
"user_agent", req.Header.Get("User-Agent"))
// Send request
resp, err := t.httpClient.Do(req)
if err != nil {
result.Error = fmt.Sprintf("HTTP request failed: %v", err)
t.logger.Debug("HTTP request failed",
"url", streamURL,
"method", method,
"error", err)
return
}
defer resp.Body.Close()
t.logger.Debug("HTTP response received",
"url", streamURL,
"status_code", resp.StatusCode,
"status", resp.Status,
"content_type", resp.Header.Get("Content-Type"),
"content_length", resp.Header.Get("Content-Length"),
"www_authenticate", resp.Header.Get("WWW-Authenticate"))
// Check status code
if resp.StatusCode != http.StatusOK {
result.Error = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)
t.logger.Debug("HTTP non-200 response",
"url", streamURL,
"status_code", resp.StatusCode,
"error", result.Error)
// Special handling for 401
if resp.StatusCode == http.StatusUnauthorized {
result.Error = "authentication required"
}
return
}
// Check content type and validate stream
t.validateHTTPStream(resp, result)
}
// validateHTTPStream validates the HTTP response as a valid stream
func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) {
@@ -442,33 +153,53 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) {
}
}
// TestStream tests if a stream URL is working (legacy method, now uses auth chain)
func (t *Tester) TestStream(ctx context.Context, streamURL, username, password string) TestResult {
// Delegate to the new auth chain method for better coverage
return t.TestStreamWithAuthChain(ctx, streamURL, username, password)
// TestStream tests if a stream URL is working
func (t *Tester) TestStream(ctx context.Context, streamURL string) TestResult {
startTime := time.Now()
result := TestResult{
URL: streamURL,
Metadata: make(map[string]interface{}),
}
// Parse URL to determine protocol
u, err := url.Parse(streamURL)
if err != nil {
result.Error = fmt.Sprintf("invalid URL: %v", err)
result.TestTime = time.Since(startTime)
return result
}
result.Protocol = u.Scheme
// Test based on protocol
switch u.Scheme {
case "rtsp", "rtsps":
t.testRTSP(ctx, streamURL, &result)
case "http", "https":
t.testHTTP(ctx, streamURL, &result)
default:
result.Error = fmt.Sprintf("unsupported protocol: %s", u.Scheme)
}
result.TestTime = time.Since(startTime)
return result
}
// testRTSP tests an RTSP stream using ffprobe
func (t *Tester) testRTSP(ctx context.Context, streamURL, username, password string, result *TestResult) {
func (t *Tester) testRTSP(ctx context.Context, streamURL string, result *TestResult) {
// Build ffprobe command
cmdCtx, cancel := context.WithTimeout(ctx, t.ffprobeTimeout)
defer cancel()
// Build URL with credentials if provided
testURL := streamURL
if username != "" && password != "" {
u, _ := url.Parse(streamURL)
u.User = url.UserPassword(username, password)
testURL = u.String()
}
// Use URL as-is - credentials already embedded if needed
args := []string{
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-show_format",
"-rtsp_transport", "tcp",
testURL,
streamURL,
}
cmd := exec.CommandContext(cmdCtx, "ffprobe", args...)
@@ -557,7 +288,7 @@ func (t *Tester) testRTSP(ctx context.Context, streamURL, username, password str
}
// testHTTP tests an HTTP stream
func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password string, result *TestResult) {
func (t *Tester) testHTTP(ctx context.Context, streamURL string, result *TestResult) {
// Create request
req, err := http.NewRequestWithContext(ctx, "GET", streamURL, nil)
if err != nil {
@@ -565,9 +296,17 @@ func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password str
return
}
// Add Basic Auth if credentials provided
if username != "" && password != "" {
req.SetBasicAuth(username, password)
// Extract credentials from URL if present
u, _ := url.Parse(streamURL)
if u.User != nil {
username := u.User.Username()
password, _ := u.User.Password()
if username != "" && password != "" {
req.SetBasicAuth(username, password)
// Remove credentials from URL for logging
u.User = nil
streamURL = u.String()
}
}
// Add headers
@@ -633,7 +372,7 @@ func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password str
result.Working = true
// Try to probe with ffprobe for more details
t.probeHTTPVideo(ctx, streamURL, username, password, result)
t.probeHTTPVideo(ctx, streamURL, result)
case strings.Contains(contentType, "text/html"), strings.Contains(contentType, "text/plain"):
// Ignore web interfaces and plain text responses
@@ -648,23 +387,17 @@ func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password str
}
// probeHTTPVideo uses ffprobe to get more details about HTTP video stream
func (t *Tester) probeHTTPVideo(ctx context.Context, streamURL, username, password string, result *TestResult) {
func (t *Tester) probeHTTPVideo(ctx context.Context, streamURL string, result *TestResult) {
cmdCtx, cancel := context.WithTimeout(ctx, t.ffprobeTimeout)
defer cancel()
// Build URL with credentials if needed
testURL := streamURL
if username != "" && password != "" && !strings.Contains(streamURL, "@") {
u, _ := url.Parse(streamURL)
u.User = url.UserPassword(username, password)
testURL = u.String()
}
// Use URL as-is - credentials already in URL if needed
args := []string{
"-v", "quiet",
"-print_format", "json",
"-show_streams",
testURL,
streamURL,
}
cmd := exec.CommandContext(cmdCtx, "ffprobe", args...)
@@ -694,7 +427,7 @@ func (t *Tester) probeHTTPVideo(ctx context.Context, streamURL, username, passwo
}
// TestMultiple tests multiple URLs concurrently
func (t *Tester) TestMultiple(ctx context.Context, urls []string, username, password string, maxConcurrent int) []TestResult {
func (t *Tester) TestMultiple(ctx context.Context, urls []string, maxConcurrent int) []TestResult {
if maxConcurrent <= 0 {
maxConcurrent = 10
}
@@ -709,7 +442,7 @@ func (t *Tester) TestMultiple(ctx context.Context, urls []string, username, pass
go func() {
defer func() { <-sem }() // Release semaphore
results[i] = t.TestStream(ctx, url, username, password)
results[i] = t.TestStream(ctx, url)
}()
}
-1
View File
@@ -67,7 +67,6 @@ type DiscoveredStream struct {
Protocol string `json:"protocol"`
Port int `json:"port"`
Working bool `json:"working"`
AuthMethod string `json:"auth_method,omitempty"` // no_auth, basic_auth, query_params, combined, digest
Resolution string `json:"resolution,omitempty"`
Codec string `json:"codec,omitempty"`
FPS int `json:"fps,omitempty"`