Add Strix camera discovery system with comprehensive database
This commit adds the complete Strix IP camera stream discovery system: - Go-based API server with SSE support for real-time updates - 3,600+ camera brand database with stream URL patterns - Intelligent fuzzy search across camera models - ONVIF discovery and stream validation - RESTful API with health check, camera search, and stream discovery - Makefile for building and deployment - Comprehensive README documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/strix-project/strix/internal/models"
|
||||
)
|
||||
|
||||
// Builder handles stream URL construction
|
||||
type Builder struct {
|
||||
queryParams []string
|
||||
logger interface{ Debug(string, ...any) }
|
||||
}
|
||||
|
||||
// NewBuilder creates a new stream URL builder
|
||||
func NewBuilder(queryParams []string, logger interface{ Debug(string, ...any) }) *Builder {
|
||||
return &Builder{
|
||||
queryParams: queryParams,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContext contains parameters for URL building
|
||||
type BuildContext struct {
|
||||
IP string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Channel int
|
||||
Width int
|
||||
Height int
|
||||
Protocol string
|
||||
Path string
|
||||
}
|
||||
|
||||
// BuildURL builds a complete URL from an entry and context
|
||||
func (b *Builder) BuildURL(entry models.CameraEntry, ctx BuildContext) string {
|
||||
// Set defaults
|
||||
if ctx.Width == 0 {
|
||||
ctx.Width = 640
|
||||
}
|
||||
if ctx.Height == 0 {
|
||||
ctx.Height = 480
|
||||
}
|
||||
|
||||
// Use entry's port if not specified
|
||||
if ctx.Port == 0 {
|
||||
ctx.Port = entry.Port
|
||||
}
|
||||
|
||||
// Use entry's protocol if not specified
|
||||
if ctx.Protocol == "" {
|
||||
ctx.Protocol = entry.Protocol
|
||||
}
|
||||
|
||||
// Replace placeholders in URL path
|
||||
path := b.replacePlaceholders(entry.URL, ctx)
|
||||
|
||||
// Build the complete URL
|
||||
var fullURL string
|
||||
|
||||
// Check if the URL already contains authentication parameters
|
||||
hasAuthInURL := b.hasAuthenticationParams(path)
|
||||
|
||||
switch ctx.Protocol {
|
||||
case "rtsp":
|
||||
if ctx.Username != "" && ctx.Password != "" && !hasAuthInURL {
|
||||
// Standard ports can be omitted
|
||||
if ctx.Port == 554 {
|
||||
fullURL = fmt.Sprintf("rtsp://%s:%s@%s/%s",
|
||||
ctx.Username, ctx.Password, ctx.IP, path)
|
||||
} else {
|
||||
fullURL = fmt.Sprintf("rtsp://%s:%s@%s:%d/%s",
|
||||
ctx.Username, ctx.Password, ctx.IP, ctx.Port, path)
|
||||
}
|
||||
} else {
|
||||
if ctx.Port == 554 {
|
||||
fullURL = fmt.Sprintf("rtsp://%s/%s", ctx.IP, path)
|
||||
} else {
|
||||
fullURL = fmt.Sprintf("rtsp://%s:%d/%s", ctx.IP, ctx.Port, path)
|
||||
}
|
||||
}
|
||||
|
||||
case "http", "https":
|
||||
// For HTTP, check if auth should be in URL or parameters
|
||||
if ctx.Username != "" && ctx.Password != "" && !hasAuthInURL {
|
||||
// Don't put auth in URL for HTTP, will use Basic Auth header
|
||||
if (ctx.Protocol == "http" && ctx.Port == 80) ||
|
||||
(ctx.Protocol == "https" && ctx.Port == 443) {
|
||||
fullURL = fmt.Sprintf("%s://%s/%s", ctx.Protocol, ctx.IP, path)
|
||||
} else {
|
||||
fullURL = fmt.Sprintf("%s://%s:%d/%s", ctx.Protocol, ctx.IP, ctx.Port, path)
|
||||
}
|
||||
} else {
|
||||
if (ctx.Protocol == "http" && ctx.Port == 80) ||
|
||||
(ctx.Protocol == "https" && ctx.Port == 443) {
|
||||
fullURL = fmt.Sprintf("%s://%s/%s", ctx.Protocol, ctx.IP, path)
|
||||
} else {
|
||||
fullURL = fmt.Sprintf("%s://%s:%d/%s", ctx.Protocol, ctx.IP, ctx.Port, path)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Generic URL construction
|
||||
fullURL = fmt.Sprintf("%s://%s:%d/%s", ctx.Protocol, ctx.IP, ctx.Port, path)
|
||||
}
|
||||
|
||||
// Clean up double slashes (except after protocol://)
|
||||
fullURL = b.cleanURL(fullURL)
|
||||
|
||||
b.logger.Debug("built stream URL", "url", fullURL, "entry", entry.Type)
|
||||
|
||||
return fullURL
|
||||
}
|
||||
|
||||
// replacePlaceholders replaces all placeholders in the URL
|
||||
func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string {
|
||||
result := urlPath
|
||||
|
||||
// Common placeholders
|
||||
replacements := map[string]string{
|
||||
"[CHANNEL]": strconv.Itoa(ctx.Channel),
|
||||
"[channel]": strconv.Itoa(ctx.Channel),
|
||||
"[WIDTH]": strconv.Itoa(ctx.Width),
|
||||
"[width]": strconv.Itoa(ctx.Width),
|
||||
"[HEIGHT]": strconv.Itoa(ctx.Height),
|
||||
"[height]": strconv.Itoa(ctx.Height),
|
||||
"[USERNAME]": ctx.Username,
|
||||
"[username]": ctx.Username,
|
||||
"[PASSWORD]": ctx.Password,
|
||||
"[password]": ctx.Password,
|
||||
"[PASWORD]": ctx.Password, // Handle typo in database
|
||||
"[pasword]": ctx.Password,
|
||||
"[USER]": ctx.Username,
|
||||
"[user]": ctx.Username,
|
||||
"[PASS]": ctx.Password,
|
||||
"[pass]": ctx.Password,
|
||||
"[PWD]": ctx.Password,
|
||||
"[pwd]": ctx.Password,
|
||||
"[IP]": ctx.IP,
|
||||
"[ip]": ctx.IP,
|
||||
"[PORT]": strconv.Itoa(ctx.Port),
|
||||
"[port]": strconv.Itoa(ctx.Port),
|
||||
"[TOKEN]": "", // Empty for now
|
||||
"[token]": "",
|
||||
}
|
||||
|
||||
// Replace all placeholders
|
||||
for placeholder, value := range replacements {
|
||||
result = strings.ReplaceAll(result, placeholder, value)
|
||||
}
|
||||
|
||||
// Handle {var} style placeholders
|
||||
result = b.replaceVarPlaceholders(result, ctx)
|
||||
|
||||
// Handle query parameter placeholders
|
||||
result = b.replaceQueryParams(result, ctx)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// replaceVarPlaceholders replaces {var} style placeholders
|
||||
func (b *Builder) replaceVarPlaceholders(urlPath string, ctx BuildContext) string {
|
||||
varPattern := regexp.MustCompile(`\{([^}]+)\}`)
|
||||
|
||||
return varPattern.ReplaceAllStringFunc(urlPath, func(match string) string {
|
||||
key := strings.Trim(match, "{}")
|
||||
switch strings.ToLower(key) {
|
||||
case "username", "user":
|
||||
return ctx.Username
|
||||
case "password", "pass", "pwd":
|
||||
return ctx.Password
|
||||
case "ip":
|
||||
return ctx.IP
|
||||
case "port":
|
||||
return strconv.Itoa(ctx.Port)
|
||||
case "channel", "chn", "ch":
|
||||
return strconv.Itoa(ctx.Channel)
|
||||
case "width":
|
||||
return strconv.Itoa(ctx.Width)
|
||||
case "height":
|
||||
return strconv.Itoa(ctx.Height)
|
||||
default:
|
||||
return match // Keep original if not recognized
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// replaceQueryParams handles query parameter replacements
|
||||
func (b *Builder) replaceQueryParams(urlPath string, ctx BuildContext) string {
|
||||
// Parse URL to handle query params
|
||||
parts := strings.SplitN(urlPath, "?", 2)
|
||||
if len(parts) < 2 {
|
||||
return urlPath
|
||||
}
|
||||
|
||||
basePath := parts[0]
|
||||
queryString := parts[1]
|
||||
|
||||
// Parse query parameters
|
||||
params, err := url.ParseQuery(queryString)
|
||||
if err != nil {
|
||||
return urlPath
|
||||
}
|
||||
|
||||
// Replace known parameter values
|
||||
for key := range params {
|
||||
lowerKey := strings.ToLower(key)
|
||||
|
||||
// Check if this is a known parameter from our list
|
||||
if b.isKnownParameter(lowerKey) {
|
||||
switch lowerKey {
|
||||
case "user", "username", "usr", "loginuse":
|
||||
params.Set(key, ctx.Username)
|
||||
case "password", "pass", "pwd", "loginpas", "passwd":
|
||||
params.Set(key, ctx.Password)
|
||||
case "channel", "chn", "ch":
|
||||
params.Set(key, strconv.Itoa(ctx.Channel))
|
||||
case "width":
|
||||
params.Set(key, strconv.Itoa(ctx.Width))
|
||||
case "height":
|
||||
params.Set(key, strconv.Itoa(ctx.Height))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild URL
|
||||
return basePath + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// isKnownParameter checks if a parameter is in our known list
|
||||
func (b *Builder) isKnownParameter(param string) bool {
|
||||
for _, known := range b.queryParams {
|
||||
if strings.ToLower(known) == param {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasAuthenticationParams checks if URL contains auth parameters
|
||||
func (b *Builder) hasAuthenticationParams(urlPath string) bool {
|
||||
authParams := []string{
|
||||
"user=", "username=", "usr=", "loginuse=",
|
||||
"password=", "pass=", "pwd=", "loginpas=", "passwd=",
|
||||
}
|
||||
|
||||
lowerPath := strings.ToLower(urlPath)
|
||||
for _, param := range authParams {
|
||||
if strings.Contains(lowerPath, param) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanURL cleans up the URL
|
||||
func (b *Builder) cleanURL(fullURL string) string {
|
||||
// Remove double slashes except after protocol://
|
||||
protocolEnd := strings.Index(fullURL, "://")
|
||||
if protocolEnd > 0 {
|
||||
protocol := fullURL[:protocolEnd+3]
|
||||
rest := fullURL[protocolEnd+3:]
|
||||
|
||||
// Replace multiple slashes with single slash
|
||||
rest = regexp.MustCompile(`/{2,}`).ReplaceAllString(rest, "/")
|
||||
|
||||
return protocol + rest
|
||||
}
|
||||
|
||||
return fullURL
|
||||
}
|
||||
|
||||
// BuildURLsFromEntry generates all possible URLs from a camera entry
|
||||
func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext) []string {
|
||||
var urls []string
|
||||
|
||||
// Build main URL
|
||||
mainURL := b.BuildURL(entry, ctx)
|
||||
urls = append(urls, mainURL)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tester validates stream URLs
|
||||
type Tester struct {
|
||||
httpClient *http.Client
|
||||
ffprobeTimeout time.Duration
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any) }
|
||||
}
|
||||
|
||||
// NewTester creates a new stream tester
|
||||
func NewTester(ffprobeTimeout time.Duration, logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *Tester {
|
||||
return &Tester{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
ffprobeTimeout: ffprobeTimeout,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// TestResult contains the test results for a stream
|
||||
type TestResult struct {
|
||||
URL string
|
||||
Working bool
|
||||
Protocol string
|
||||
Type string
|
||||
Resolution string
|
||||
Codec string
|
||||
FPS int
|
||||
Bitrate int
|
||||
HasAudio bool
|
||||
Error string
|
||||
TestTime time.Duration
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
// TestStream tests if a stream URL is working
|
||||
func (t *Tester) TestStream(ctx context.Context, streamURL, username, password 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, username, password, &result)
|
||||
case "http", "https":
|
||||
t.testHTTP(ctx, streamURL, username, password, &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) {
|
||||
// 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()
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
"-rtsp_transport", "tcp",
|
||||
testURL,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "ffprobe", args...)
|
||||
|
||||
// Capture output
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
t.logger.Debug("testing RTSP stream", "url", streamURL)
|
||||
|
||||
// Execute command
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
result.Error = "timeout while testing stream"
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("ffprobe failed: %v", err)
|
||||
if stderr.Len() > 0 {
|
||||
result.Error += fmt.Sprintf(" (stderr: %s)", stderr.String())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse ffprobe output
|
||||
var probeResult struct {
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
AvgFrameRate string `json:"avg_frame_rate"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
BitRate string `json:"bit_rate"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(stdout.Bytes(), &probeResult); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to parse ffprobe output: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract stream information
|
||||
result.Working = len(probeResult.Streams) > 0
|
||||
result.Type = "FFMPEG"
|
||||
|
||||
for _, stream := range probeResult.Streams {
|
||||
if stream.CodecType == "video" {
|
||||
result.Codec = stream.CodecName
|
||||
result.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
|
||||
|
||||
// Parse frame rate
|
||||
if stream.AvgFrameRate != "" {
|
||||
parts := strings.Split(stream.AvgFrameRate, "/")
|
||||
if len(parts) == 2 {
|
||||
// Calculate FPS from fraction
|
||||
var num, den int
|
||||
fmt.Sscanf(parts[0], "%d", &num)
|
||||
fmt.Sscanf(parts[1], "%d", &den)
|
||||
if den > 0 {
|
||||
result.FPS = num / den
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse bitrate
|
||||
if stream.BitRate != "" {
|
||||
fmt.Sscanf(stream.BitRate, "%d", &result.Bitrate)
|
||||
}
|
||||
} else if stream.CodecType == "audio" {
|
||||
result.HasAudio = true
|
||||
}
|
||||
}
|
||||
|
||||
// Use format bitrate if stream bitrate not available
|
||||
if result.Bitrate == 0 && probeResult.Format.BitRate != "" {
|
||||
fmt.Sscanf(probeResult.Format.BitRate, "%d", &result.Bitrate)
|
||||
}
|
||||
|
||||
if !result.Working {
|
||||
result.Error = "no streams found"
|
||||
}
|
||||
}
|
||||
|
||||
// testHTTP tests an HTTP stream
|
||||
func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password string, 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
|
||||
}
|
||||
|
||||
// Add Basic Auth if credentials provided
|
||||
if username != "" && password != "" {
|
||||
req.SetBasicAuth(username, password)
|
||||
}
|
||||
|
||||
// Add headers
|
||||
req.Header.Set("User-Agent", "Strix/1.0")
|
||||
|
||||
t.logger.Debug("testing HTTP stream", "url", streamURL)
|
||||
|
||||
// Send request
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("HTTP request failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
result.Error = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)
|
||||
|
||||
// Special handling for 401
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
result.Error = "authentication required"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check content type
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
result.Metadata["content_type"] = contentType
|
||||
|
||||
// Determine stream type based on content type
|
||||
switch {
|
||||
case strings.Contains(contentType, "multipart"):
|
||||
result.Type = "MJPEG"
|
||||
result.Working = true
|
||||
|
||||
// Read first few bytes to verify
|
||||
buffer := make([]byte, 512)
|
||||
n, _ := resp.Body.Read(buffer)
|
||||
if n > 0 {
|
||||
// Check for MJPEG boundary
|
||||
if bytes.Contains(buffer[:n], []byte("--")) {
|
||||
result.Working = true
|
||||
}
|
||||
}
|
||||
|
||||
case strings.Contains(contentType, "image/jpeg"):
|
||||
result.Type = "JPEG"
|
||||
result.Working = true
|
||||
|
||||
// Read first few bytes to verify JPEG magic bytes
|
||||
buffer := make([]byte, 3)
|
||||
n, _ := resp.Body.Read(buffer)
|
||||
if n >= 3 && buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF {
|
||||
result.Working = true
|
||||
} else {
|
||||
result.Working = false
|
||||
result.Error = "invalid JPEG data"
|
||||
}
|
||||
|
||||
case strings.Contains(contentType, "video"):
|
||||
result.Type = "HTTP_VIDEO"
|
||||
result.Working = true
|
||||
|
||||
// Try to probe with ffprobe for more details
|
||||
t.probeHTTPVideo(ctx, streamURL, username, password, result)
|
||||
|
||||
default:
|
||||
result.Type = "HTTP_UNKNOWN"
|
||||
result.Working = true // Assume it works if we got 200 OK
|
||||
result.Metadata["note"] = "unknown content type, may still be valid"
|
||||
}
|
||||
}
|
||||
|
||||
// probeHTTPVideo uses ffprobe to get more details about HTTP video stream
|
||||
func (t *Tester) probeHTTPVideo(ctx context.Context, streamURL, username, password 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()
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
testURL,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "ffprobe", args...)
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
|
||||
if err := cmd.Run(); err == nil {
|
||||
var probeResult struct {
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
if json.Unmarshal(stdout.Bytes(), &probeResult) == nil {
|
||||
for _, stream := range probeResult.Streams {
|
||||
if stream.CodecType == "video" {
|
||||
result.Codec = stream.CodecName
|
||||
result.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiple tests multiple URLs concurrently
|
||||
func (t *Tester) TestMultiple(ctx context.Context, urls []string, username, password string, maxConcurrent int) []TestResult {
|
||||
if maxConcurrent <= 0 {
|
||||
maxConcurrent = 10
|
||||
}
|
||||
|
||||
results := make([]TestResult, len(urls))
|
||||
sem := make(chan struct{}, maxConcurrent)
|
||||
|
||||
for i, url := range urls {
|
||||
i, url := i, url // Capture for goroutine
|
||||
|
||||
sem <- struct{}{} // Acquire semaphore
|
||||
go func() {
|
||||
defer func() { <-sem }() // Release semaphore
|
||||
|
||||
results[i] = t.TestStream(ctx, url, username, password)
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
for i := 0; i < maxConcurrent; i++ {
|
||||
sem <- struct{}{}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// IsFFProbeAvailable checks if ffprobe is available
|
||||
func (t *Tester) IsFFProbeAvailable() bool {
|
||||
cmd := exec.Command("ffprobe", "-version")
|
||||
err := cmd.Run()
|
||||
return err == nil
|
||||
}
|
||||
Reference in New Issue
Block a user