From 35293dec832b6132ee870844adf94ff64da2b14f Mon Sep 17 00:00:00 2001 From: eduard256 Date: Sun, 9 Nov 2025 18:09:04 +0300 Subject: [PATCH] Add BUBBLE protocol support for XMeye/HiSilicon NVR/DVR cameras Implemented comprehensive BUBBLE protocol support for Chinese NVR/DVR cameras (ZOSI, SANNCE, ANNKE, FLOUREON, XMeye). This proprietary protocol requires HTTP with embedded credentials and special handling. Changes: - Added BUBBLE entries to brand databases with main/sub stream support - Extended URL placeholder system to support {channel} syntax - Implemented BUBBLE-specific stream generation with credential embedding - Added BUBBLE stream detection via Content-Type: video/bubble - Updated Frigate/Go2RTC generators to convert BUBBLE URLs to bubble:// format - Added BUBBLE patterns to popular stream database Technical details: - BUBBLE uses HTTP protocol with credentials in URL (bubble://user:pass@host:port/path) - Supports dual streams: stream=0 (main) and stream=1 (sub) - Requires video=copy parameter for optimal performance in go2rtc - Detection prioritized before generic HTTP checks to ensure correct identification --- data/brands/annke.json | 32 ++++++++++++++++ data/brands/floureon.json | 32 ++++++++++++++++ data/brands/sannce.json | 32 ++++++++++++++++ data/brands/xmeye.json | 32 ++++++++++++++++ data/brands/zosi.json | 32 ++++++++++++++++ data/popular_stream_patterns.json | 16 ++++++++ internal/camera/stream/builder.go | 24 ++++++++++++ internal/camera/stream/tester.go | 37 ++++++++++++++----- .../web/js/config-generators/frigate/index.js | 15 ++++++++ .../web/js/config-generators/go2rtc/index.js | 24 ++++++++++++ 10 files changed, 266 insertions(+), 10 deletions(-) diff --git a/data/brands/annke.json b/data/brands/annke.json index bb7bd75..114f226 100644 --- a/data/brands/annke.json +++ b/data/brands/annke.json @@ -4,6 +4,38 @@ "last_updated": "2025-10-17", "source": "ispyconnect.com", "entries": [ + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=0", + "auth_required": true, + "notes": "Bubble Protocol - main stream (works with go2rtc bubble:// source)" + }, + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=1", + "auth_required": true, + "notes": "Bubble Protocol - sub stream (lower quality)" + }, { "models": [ "cheap p-t", diff --git a/data/brands/floureon.json b/data/brands/floureon.json index 1817658..93b5adb 100644 --- a/data/brands/floureon.json +++ b/data/brands/floureon.json @@ -4,6 +4,38 @@ "last_updated": "2025-10-17", "source": "ispyconnect.com", "entries": [ + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=0", + "auth_required": true, + "notes": "Bubble Protocol - main stream (works with go2rtc bubble:// source)" + }, + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=1", + "auth_required": true, + "notes": "Bubble Protocol - sub stream (lower quality)" + }, { "models": [ "1080", diff --git a/data/brands/sannce.json b/data/brands/sannce.json index e26f078..b26d880 100644 --- a/data/brands/sannce.json +++ b/data/brands/sannce.json @@ -4,6 +4,38 @@ "last_updated": "2025-10-17", "source": "ispyconnect.com", "entries": [ + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=0", + "auth_required": true, + "notes": "Bubble Protocol - main stream (works with go2rtc bubble:// source)" + }, + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=1", + "auth_required": true, + "notes": "Bubble Protocol - sub stream (lower quality)" + }, { "models": [ "1080p", diff --git a/data/brands/xmeye.json b/data/brands/xmeye.json index 19301d8..d1a7ba9 100644 --- a/data/brands/xmeye.json +++ b/data/brands/xmeye.json @@ -4,6 +4,38 @@ "last_updated": "2025-10-17", "source": "ispyconnect.com", "entries": [ + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=0", + "auth_required": true, + "notes": "Bubble Protocol - main stream (works with go2rtc bubble:// source)" + }, + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=1", + "auth_required": true, + "notes": "Bubble Protocol - sub stream (lower quality)" + }, { "models": [ "000", diff --git a/data/brands/zosi.json b/data/brands/zosi.json index 8498fbe..a118366 100644 --- a/data/brands/zosi.json +++ b/data/brands/zosi.json @@ -4,6 +4,38 @@ "last_updated": "2025-10-17", "source": "ispyconnect.com", "entries": [ + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=0", + "auth_required": true, + "notes": "Bubble Protocol - main stream (works with go2rtc bubble:// source)" + }, + { + "models": [ + "NVR", + "DVR", + "H.264", + "H.265", + "HiSilicon", + "Other" + ], + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "url": "/bubble/live?ch={channel}&stream=1", + "auth_required": true, + "notes": "Bubble Protocol - sub stream (lower quality)" + }, { "models": [ "1080", diff --git a/data/popular_stream_patterns.json b/data/popular_stream_patterns.json index 1545d8c..5af16e7 100644 --- a/data/popular_stream_patterns.json +++ b/data/popular_stream_patterns.json @@ -1630,5 +1630,21 @@ "port": 554, "notes": "", "model_count": 111 + }, + { + "url": "/bubble/live?ch={channel}&stream=0", + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "notes": "Bubble Protocol main stream (XMeye/HiSilicon NVR/DVR - ZOSI, SANNCE, ANNKE, FLOUREON)", + "model_count": 5000 + }, + { + "url": "/bubble/live?ch={channel}&stream=1", + "type": "BUBBLE", + "protocol": "bubble", + "port": 80, + "notes": "Bubble Protocol sub stream (XMeye/HiSilicon NVR/DVR - lower quality)", + "model_count": 5000 } ] \ No newline at end of file diff --git a/internal/camera/stream/builder.go b/internal/camera/stream/builder.go index 7fb85ee..7c9ed0c 100644 --- a/internal/camera/stream/builder.go +++ b/internal/camera/stream/builder.go @@ -176,6 +176,8 @@ func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string { replacements := map[string]string{ "[CHANNEL]": strconv.Itoa(ctx.Channel), "[channel]": strconv.Itoa(ctx.Channel), + "{channel}": strconv.Itoa(ctx.Channel), // BUBBLE protocol uses {channel} + "{CHANNEL}": strconv.Itoa(ctx.Channel), "[WIDTH]": strconv.Itoa(ctx.Width), "[width]": strconv.Itoa(ctx.Width), "[HEIGHT]": strconv.Itoa(ctx.Height), @@ -298,6 +300,28 @@ func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext) } switch entry.Protocol { + case "bubble": + // BUBBLE protocol: proprietary Chinese NVR/DVR protocol + // Always use HTTP with embedded credentials + if ctx.Username != "" && ctx.Password != "" { + // Build HTTP URL with credentials embedded + ctxHTTP := ctx + ctxHTTP.Protocol = "http" + + baseURL := b.BuildURL(entry, ctxHTTP) + + // Parse and add credentials to URL + if u, err := url.Parse(baseURL); err == nil { + u.User = url.UserPassword(ctx.Username, ctx.Password) + addURL(u.String()) + } + } else { + // No credentials - try anyway (some cameras might work) + ctxHTTP := ctx + ctxHTTP.Protocol = "http" + addURL(b.BuildURL(entry, ctxHTTP)) + } + case "rtsp", "rtsps": // For RTSP: generate with and without credentials if ctx.Username != "" && ctx.Password != "" { diff --git a/internal/camera/stream/tester.go b/internal/camera/stream/tester.go index 329770c..f938fda 100644 --- a/internal/camera/stream/tester.go +++ b/internal/camera/stream/tester.go @@ -74,7 +74,24 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) { "has_jpeg_magic", hasJPEGMagic, "has_mjpeg_boundary", hasMJPEGBoundary) - // 1. Check Content-Type for multipart (MJPEG) + // 1. Check for BUBBLE protocol (highest priority for this specific type) + if contentType == "video/bubble" { + result.Type = "BUBBLE" + result.Working = true + + // Extract stream type from full URL (not just path) for metadata + fullURL := resp.Request.URL.String() + if strings.Contains(fullURL, "stream=1") { + result.Metadata["stream_type"] = "sub" + } else { + result.Metadata["stream_type"] = "main" + } + + t.logger.Debug("detected BUBBLE stream", "stream_type", result.Metadata["stream_type"], "url", fullURL) + return + } + + // 2. Check Content-Type for multipart (MJPEG) if strings.Contains(contentType, "multipart") { result.Type = "MJPEG" result.Working = hasMJPEGBoundary @@ -85,7 +102,7 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) { return } - // 2. Check for JPEG by magic bytes (most reliable) + // 3. Check for JPEG by magic bytes (most reliable) if hasJPEGMagic { // Verify it's not MJPEG if hasMJPEGBoundary { @@ -100,7 +117,7 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) { return } - // 3. Check Content-Type for image/jpeg + // 4. Check Content-Type for image/jpeg if strings.Contains(contentType, "image/jpeg") || strings.Contains(contentType, "image/jpg") { result.Type = "JPEG" result.Working = true @@ -108,7 +125,7 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) { return } - // 4. Check URL patterns for JPEG (fallback for cameras with wrong Content-Type) + // 5. Check URL patterns for JPEG (fallback for cameras with wrong Content-Type) jpegPatterns := []string{".jpg", ".jpeg", "snapshot", "image", "picture", "snap", "photo", "capture"} for _, pattern := range jpegPatterns { if strings.Contains(urlPath, pattern) { @@ -120,7 +137,7 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) { } } - // 5. Check for MJPEG by extension + // 6. Check for MJPEG by extension if strings.Contains(urlPath, ".mjpg") || strings.Contains(urlPath, ".mjpeg") { result.Type = "MJPEG" result.Working = true @@ -128,7 +145,7 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) { return } - // 6. Check for HLS + // 7. Check for HLS if strings.Contains(urlPath, ".m3u8") || strings.Contains(contentType, "application/vnd.apple.mpegurl") || strings.Contains(contentType, "application/x-mpegurl") { @@ -137,28 +154,28 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) { return } - // 7. Check for MPEG-DASH + // 8. Check for MPEG-DASH if strings.Contains(urlPath, ".mpd") || strings.Contains(contentType, "application/dash+xml") { result.Type = "MPEG-DASH" result.Working = true return } - // 8. Check for video content type + // 9. Check for video content type if strings.Contains(contentType, "video") { result.Type = "HTTP_VIDEO" result.Working = true return } - // 9. Check for web interface + // 10. Check for web interface if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "text/plain") { result.Working = false result.Error = "web interface, not a video stream" return } - // 10. Unknown - but still working if we got 200 OK + // 11. Unknown - but still working if we got 200 OK result.Type = "HTTP_UNKNOWN" result.Working = true result.Metadata["note"] = "unknown content type, may still be valid" diff --git a/webui/web/js/config-generators/frigate/index.js b/webui/web/js/config-generators/frigate/index.js index 5682861..4efe8f9 100644 --- a/webui/web/js/config-generators/frigate/index.js +++ b/webui/web/js/config-generators/frigate/index.js @@ -144,6 +144,21 @@ export class FrigateGenerator { } } + // Handle BUBBLE protocol - convert to bubble:// format for go2rtc + if (stream.type === 'BUBBLE') { + try { + const urlObj = new URL(stream.url); + const username = urlObj.username || 'admin'; + const password = urlObj.password || ''; + const host = urlObj.hostname; + const port = urlObj.port || '80'; + const path = urlObj.pathname + urlObj.search; + return `bubble://${username}:${password}@${host}:${port}${path}#video=copy`; + } catch (e) { + return stream.url; + } + } + // For all other types (RTSP, MJPEG, HLS, HTTP-FLV, RTMP, etc.): use direct URL // Go2RTC handles these formats natively return stream.url; diff --git a/webui/web/js/config-generators/go2rtc/index.js b/webui/web/js/config-generators/go2rtc/index.js index ce963ac..b685530 100644 --- a/webui/web/js/config-generators/go2rtc/index.js +++ b/webui/web/js/config-generators/go2rtc/index.js @@ -60,6 +60,11 @@ export class Go2RTCGenerator { return this.generateONVIFSource(stream); } + // Handle BUBBLE protocol + if (stream.type === 'BUBBLE') { + return this.generateBubbleSource(stream); + } + // For all other types: use direct URL return stream.url; } @@ -100,4 +105,23 @@ export class Go2RTCGenerator { return stream.url; } } + + /** + * Generate BUBBLE source + * Converts HTTP BUBBLE endpoint to bubble:// format for go2rtc + * Format: bubble://user:pass@host:port/bubble/live?ch=X&stream=Y#video=copy + */ + static generateBubbleSource(stream) { + try { + const urlObj = new URL(stream.url); + const username = urlObj.username || 'admin'; + const password = urlObj.password || ''; + const host = urlObj.hostname; + const port = urlObj.port || '80'; + const path = urlObj.pathname + urlObj.search; + return `bubble://${username}:${password}@${host}:${port}${path}#video=copy`; + } catch (e) { + return stream.url; + } + } }