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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user