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
This commit is contained in:
eduard256
2025-11-09 18:09:04 +03:00
parent 75afc987f4
commit 35293dec83
10 changed files with 266 additions and 10 deletions
+32
View File
@@ -4,6 +4,38 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"cheap p-t", "cheap p-t",
+32
View File
@@ -4,6 +4,38 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"1080", "1080",
+32
View File
@@ -4,6 +4,38 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"1080p", "1080p",
+32
View File
@@ -4,6 +4,38 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"000", "000",
+32
View File
@@ -4,6 +4,38 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"1080", "1080",
+16
View File
@@ -1630,5 +1630,21 @@
"port": 554, "port": 554,
"notes": "", "notes": "",
"model_count": 111 "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
} }
] ]
+24
View File
@@ -176,6 +176,8 @@ func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string {
replacements := map[string]string{ replacements := map[string]string{
"[CHANNEL]": strconv.Itoa(ctx.Channel), "[CHANNEL]": strconv.Itoa(ctx.Channel),
"[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),
"[width]": strconv.Itoa(ctx.Width), "[width]": strconv.Itoa(ctx.Width),
"[HEIGHT]": strconv.Itoa(ctx.Height), "[HEIGHT]": strconv.Itoa(ctx.Height),
@@ -298,6 +300,28 @@ func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext)
} }
switch entry.Protocol { 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": case "rtsp", "rtsps":
// For RTSP: generate with and without credentials // For RTSP: generate with and without credentials
if ctx.Username != "" && ctx.Password != "" { if ctx.Username != "" && ctx.Password != "" {
+27 -10
View File
@@ -74,7 +74,24 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) {
"has_jpeg_magic", hasJPEGMagic, "has_jpeg_magic", hasJPEGMagic,
"has_mjpeg_boundary", hasMJPEGBoundary) "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") { if strings.Contains(contentType, "multipart") {
result.Type = "MJPEG" result.Type = "MJPEG"
result.Working = hasMJPEGBoundary result.Working = hasMJPEGBoundary
@@ -85,7 +102,7 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) {
return return
} }
// 2. Check for JPEG by magic bytes (most reliable) // 3. Check for JPEG by magic bytes (most reliable)
if hasJPEGMagic { if hasJPEGMagic {
// Verify it's not MJPEG // Verify it's not MJPEG
if hasMJPEGBoundary { if hasMJPEGBoundary {
@@ -100,7 +117,7 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) {
return 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") { if strings.Contains(contentType, "image/jpeg") || strings.Contains(contentType, "image/jpg") {
result.Type = "JPEG" result.Type = "JPEG"
result.Working = true result.Working = true
@@ -108,7 +125,7 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) {
return 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"} jpegPatterns := []string{".jpg", ".jpeg", "snapshot", "image", "picture", "snap", "photo", "capture"}
for _, pattern := range jpegPatterns { for _, pattern := range jpegPatterns {
if strings.Contains(urlPath, pattern) { 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") { if strings.Contains(urlPath, ".mjpg") || strings.Contains(urlPath, ".mjpeg") {
result.Type = "MJPEG" result.Type = "MJPEG"
result.Working = true result.Working = true
@@ -128,7 +145,7 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) {
return return
} }
// 6. Check for HLS // 7. Check for HLS
if strings.Contains(urlPath, ".m3u8") || if strings.Contains(urlPath, ".m3u8") ||
strings.Contains(contentType, "application/vnd.apple.mpegurl") || strings.Contains(contentType, "application/vnd.apple.mpegurl") ||
strings.Contains(contentType, "application/x-mpegurl") { strings.Contains(contentType, "application/x-mpegurl") {
@@ -137,28 +154,28 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) {
return return
} }
// 7. Check for MPEG-DASH // 8. Check for MPEG-DASH
if strings.Contains(urlPath, ".mpd") || strings.Contains(contentType, "application/dash+xml") { if strings.Contains(urlPath, ".mpd") || strings.Contains(contentType, "application/dash+xml") {
result.Type = "MPEG-DASH" result.Type = "MPEG-DASH"
result.Working = true result.Working = true
return return
} }
// 8. Check for video content type // 9. Check for video content type
if strings.Contains(contentType, "video") { if strings.Contains(contentType, "video") {
result.Type = "HTTP_VIDEO" result.Type = "HTTP_VIDEO"
result.Working = true result.Working = true
return return
} }
// 9. Check for web interface // 10. Check for web interface
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "text/plain") { if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "text/plain") {
result.Working = false result.Working = false
result.Error = "web interface, not a video stream" result.Error = "web interface, not a video stream"
return 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.Type = "HTTP_UNKNOWN"
result.Working = true result.Working = true
result.Metadata["note"] = "unknown content type, may still be valid" result.Metadata["note"] = "unknown content type, may still be valid"
@@ -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 // For all other types (RTSP, MJPEG, HLS, HTTP-FLV, RTMP, etc.): use direct URL
// Go2RTC handles these formats natively // Go2RTC handles these formats natively
return stream.url; return stream.url;
@@ -60,6 +60,11 @@ export class Go2RTCGenerator {
return this.generateONVIFSource(stream); return this.generateONVIFSource(stream);
} }
// Handle BUBBLE protocol
if (stream.type === 'BUBBLE') {
return this.generateBubbleSource(stream);
}
// For all other types: use direct URL // For all other types: use direct URL
return stream.url; return stream.url;
} }
@@ -100,4 +105,23 @@ export class Go2RTCGenerator {
return stream.url; 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;
}
}
} }