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