15 Commits

Author SHA1 Message Date
eduard256 0c0d743594 Merge develop into main for v1.0.9 release 2025-12-11 16:40:39 +00:00
eduard256 787919d20b Release v1.0.9: Fix SSE real-time streaming in Home Assistant Ingress mode 2025-12-11 16:40:31 +00:00
eduard256 e9dc04178e Fix SSE real-time streaming in Home Assistant Ingress mode
Add padding to overcome aiohttp 64KB buffer in HA Supervisor.

Problem:
- HA Supervisor uses aiohttp with 64KB StreamResponse buffer
- Small SSE events (~200-500 bytes) were buffered until connection closed
- Users saw all streams appear at once instead of real-time updates

Solution:
- Detect Ingress mode via X-Ingress-Path header
- Add 64KB SSE comment padding to fill proxy buffers
- Increase progress interval to 3 sec in Ingress mode (reduce traffic)
- Normal mode (Docker/direct) unchanged - works exactly as before

Traffic impact:
- Normal mode: ~17KB per scan (unchanged)
- Ingress mode: ~2-3MB per scan (acceptable for real-time updates)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 16:34:05 +00:00
eduard256 915c1dec1b Add stream categorization: Recommended/Alternative with Main/Sub/Other subgroups
- Split discovered streams into Recommended (FFMPEG, ONVIF) and Alternative groups
- Add Main/Sub/Other classification within Recommended based on resolution:
  - Main: streams with width >= 720px (after clustering analysis)
  - Sub: streams with width < 720px or lower cluster
  - Other: streams without resolution info
- Implement smart auto-collapse based on selection mode:
  - Main selection: shows Recommended/Main, collapses rest
  - Sub selection: shows Recommended/Sub, collapses rest
  - Falls back to showing all if target category is empty
- Add collapsible groups/subgroups with chevron toggles
- User manual expand/collapse preserved until mode change
- Add stream count badges to all group headers
2025-12-11 09:49:46 +00:00
eduard256 e6828d8a22 Add ?mp4 parameter support for BUBBLE streams in Frigate config
- Add buildRtspPath() helper method to conditionally append ?mp4
- Only BUBBLE stream types get ?mp4 suffix for proper recording
- Other stream types (RTSP, ONVIF, JPEG, etc.) remain unchanged
- Handles both single and dual-stream configurations correctly
2025-11-26 15:29:56 +03:00
eduard256 eedce14731 Merge branch 'develop' 2025-11-26 13:01:32 +03:00
eduard256 9975aa71de Release v1.0.8: Use host network mode for Docker deployments 2025-11-26 13:01:27 +03:00
eduard256 38e4af230f Use host network mode for Docker deployments
- Update docker run command to use --network host
- Update docker-compose.yml to use network_mode: host
- Update docker-compose.full.yml to use network_mode: host
- Remove port mappings as they are not needed with host network
2025-11-26 12:57:45 +03:00
eduard256 031e494787 Merge develop into main for v1.0.7 release 2025-11-23 22:54:20 +03:00
eduard256 de389588ce Release v1.0.7: Fix Hikvision channel numbering and improve database
- Fixed channel numbering for Hikvision-style cameras (reported by @sergbond_com)
- Added universal [CHANNEL+1] placeholder support
- Supports both 0-based and 1-based channel numbering
- Updated 14 camera brands with universal patterns
- Fixed brand+model search matching
- Removed invalid test data from database
2025-11-23 22:54:03 +03:00
eduard256 4c03ad8d3c Add [CHANNEL+1] placeholder support for Hikvision-style channel numbering
- Added [CHANNEL+1], [channel+1], {CHANNEL+1}, {channel+1} placeholders to builder.go
- Updated 14 camera brands with universal channel patterns
- Hikvision: replaced 10 hardcoded patterns with 6 universal patterns
- Hiwatch: replaced 4 hardcoded patterns with 8 universal patterns (including ISAPI)
- Other brands: Annke, Swann, Abus, 7links, LevelOne, AlienDVR, Oswoo, AV102IP-40, Acvil, TBKVision, Deltaco, Night Owl
- Universal patterns placed first for faster discovery, hardcoded patterns kept as fallback
- Supports both 0-based (channel=0 -> 101) and 1-based (channel=1 -> 101) numbering
- Added 6 high-priority patterns to popular_stream_patterns.json
2025-11-23 22:39:20 +03:00
eduard256 d569a76700 Use intelligent brand+model search in stream discovery 2025-11-23 21:33:44 +03:00
eduard256 a405d6198f Merge main into develop: Add dynamic channel support for HiWatch cameras 2025-11-22 22:22:41 +03:00
eduard256 4143c267cd Remove invalid URL entry from Hikvision database
- Removed entry with embedded credentials and IP address
- Entry contained: rtsp://huntertech:Superuser01!@10.0.55.11:554
- This was likely test data that accidentally got committed
- Model "Bullet-4K" entry removed from database
2025-11-22 21:52:53 +03:00
eduard256 19e58db70f Add dynamic channel support for HiWatch cameras
- Added 5 new URL patterns with [CHANNEL] placeholder
- Supports channels 0-255 for multi-camera DVR/NVR systems
- Patterns include /Streaming/Channels/[CHANNEL]01, [CHANNEL]02
- ISAPI format support with dynamic channels
- All existing hardcoded patterns preserved for compatibility
2025-11-22 21:45:56 +03:00
29 changed files with 2761 additions and 1494 deletions
+48
View File
@@ -5,6 +5,54 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.9] - 2025-12-11
### Fixed
- Fixed real-time SSE streaming in Home Assistant Ingress mode
- SSE events now arrive immediately instead of being buffered until completion
### Technical
- Added automatic detection of Home Assistant Ingress via X-Ingress-Path header
- Implemented 64KB padding for SSE events to overcome aiohttp buffer in HA Supervisor
- Adjusted progress update interval to 3 seconds in Ingress mode to reduce traffic
- Normal mode (Docker/direct access) remains unchanged
## [1.0.8] - 2025-11-26
### Changed
- Updated Docker deployment to use host network mode for better compatibility
- Modified docker-compose.yml to use `network_mode: host`
- Updated installation commands to use `--network host` flag
- Removed port mappings as they are not needed with host network mode
### Improved
- Better compatibility with unprivileged LXC containers
- Simplified Docker networking configuration
- Direct network access for improved camera discovery performance
## [1.0.7] - 2025-11-23
### Fixed
- Fixed channel numbering for Hikvision-style cameras (reported by @sergbond_com)
- Removed invalid test data from Hikvision database
- Fixed brand+model search matching in stream discovery
### Added
- Universal `[CHANNEL+1]` placeholder support for flexible channel numbering
- Support for both 0-based (channel=0 → 101) and 1-based (channel=1 → 101) channel selection
- Added 6 high-priority Hikvision patterns to popular stream patterns database
### Changed
- Updated 14 camera brands with universal channel patterns (Hikvision, Hiwatch, Annke, Swann, Abus, 7links, LevelOne, AlienDVR, Oswoo, AV102IP-40, Acvil, TBKVision, Deltaco, Night Owl)
- Hikvision: replaced 10 hardcoded patterns with 6 universal patterns
- Hiwatch: replaced 4 hardcoded patterns with 8 universal patterns (including ISAPI variants)
- Universal patterns now tested first for faster discovery, hardcoded patterns kept as fallback
- Improved stream discovery performance with intelligent pattern ordering
### Technical
- Added support for `[CHANNEL+1]`, `[channel+1]`, `{CHANNEL+1}`, `{channel+1}` placeholders in URL builder
- Modified 16 files: +2448 additions, -1954 deletions
## [0.1.0] - 2025-11-06
### Added
+1 -1
View File
@@ -38,7 +38,7 @@
### Ubuntu / Debian
```bash
sudo apt update && command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh && docker run -d --name strix -p 4567:4567 --restart unless-stopped eduard256/strix:latest
sudo apt update && command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh && docker run -d --name strix --network host --restart unless-stopped eduard256/strix:latest
```
Open **http://YOUR_SERVER_IP:4567**
+1 -1
View File
@@ -20,7 +20,7 @@ import (
const (
// Version is the application version
Version = "1.0.4"
Version = "1.0.9"
// Banner is the application banner
Banner = `
+64 -28
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"3628-675",
@@ -313,15 +349,6 @@
"port": 0,
"url": ""
},
{
"models": [
"IPC-300"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/101"
},
{
"models": [
"IPC-340HD",
@@ -465,15 +492,6 @@
"port": 0,
"url": "snapshot.jpg"
},
{
"models": [
"IPC-740"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"IP-CAM",
@@ -631,16 +649,6 @@
"port": 80,
"url": "/videostream.asf?user=[USERNAME]&pwd=[PASSWORD]&resolution=320x240"
},
{
"models": [
"PX3615",
"SK7008-T1F1"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/401"
},
{
"models": [
"PX-3615-675"
@@ -722,6 +730,34 @@
"protocol": "http",
"port": 82,
"url": "/cgi/mjpg/mjpg.cgi"
},
{
"models": [
"IPC-300"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/101"
},
{
"models": [
"IPC-740"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"PX3615",
"SK7008-T1F1"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/401"
}
]
}
+61 -25
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"10550",
@@ -320,31 +356,6 @@
"port": 554,
"url": "/s2"
},
{
"models": [
"IPCA53000",
"IPCB42510B",
"IPCB44510A",
"IPCB64515B"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"IPCB42550",
"IPCB78520",
"NVR10030",
"TVIP41500",
"TVIP52500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"IPCB54611B",
@@ -635,6 +646,31 @@
"protocol": "rtsp",
"port": 554,
"url": "/mpeg4/media.amp?resolution=640x480"
},
{
"models": [
"IPCA53000",
"IPCB42510B",
"IPCB44510A",
"IPCB64515B"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"IPCB42550",
"IPCB78520",
"NVR10030",
"TVIP41500",
"TVIP52500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
}
]
}
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"WIFI-5MP-30"
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"mega216"
+127 -91
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"NVR",
@@ -220,55 +256,6 @@
"port": 0,
"url": "snapshot.jpg?user=[USERNAME]&pwd=[PASSWORD]&strm=[CHANNEL]"
},
{
"models": [
"141CS",
"151DB",
"151de",
"151dj",
"151DM",
"191BS",
"2MP",
"4MP Bullet",
"4MP DOME",
"720P",
"AC500",
"AK-N48PIA0-68DT",
"c500",
"C800",
"DE81GB",
"DN41R",
"DN81R",
"DVR",
"DW81KD",
"i15dx",
"i51dm",
"I51DS",
"I51DX",
"I61BK",
"I61DR",
"I61FC",
"I61G",
"I91BD",
"I91BF",
"I91BM",
"I91F",
"l51DM",
"N481Y",
"N48PI",
"NC400",
"NC800",
"NCPT500",
"Other",
"P01",
"POE",
"VIEW"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"141CS",
@@ -498,39 +485,6 @@
"port": 554,
"url": "/onvif2"
},
{
"models": [
"191BS",
"AC500",
"c800",
"C800-4k",
"I51DX",
"I91BF",
"NC800"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"191df"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/102"
},
{
"models": [
"191df"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/101"
},
{
"models": [
"2MP",
@@ -659,15 +613,6 @@
"port": 80,
"url": "/cgi-bin/snapshot.cgi?chn=4&u=[USERNAME]&p=[PASSWORD]"
},
{
"models": [
"DVR"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/201"
},
{
"models": [
"h264",
@@ -851,6 +796,97 @@
"protocol": "rtsp",
"port": 0,
"url": "/h264/ch1/main/av_stream"
},
{
"models": [
"141CS",
"151DB",
"151de",
"151dj",
"151DM",
"191BS",
"2MP",
"4MP Bullet",
"4MP DOME",
"720P",
"AC500",
"AK-N48PIA0-68DT",
"c500",
"C800",
"DE81GB",
"DN41R",
"DN81R",
"DVR",
"DW81KD",
"i15dx",
"i51dm",
"I51DS",
"I51DX",
"I61BK",
"I61DR",
"I61FC",
"I61G",
"I91BD",
"I91BF",
"I91BM",
"I91F",
"l51DM",
"N481Y",
"N48PI",
"NC400",
"NC800",
"NCPT500",
"Other",
"P01",
"POE",
"VIEW"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"191BS",
"AC500",
"c800",
"C800-4k",
"I51DX",
"I91BF",
"NC800"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"191df"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/102"
},
{
"models": [
"191df"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/101"
},
{
"models": [
"DVR"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/201"
}
]
}
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"Other"
+36
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"Outdoor Smart Home Camera",
+1097 -1052
View File
File diff suppressed because it is too large Load Diff
+200 -83
View File
@@ -4,6 +4,123 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]"
},
{
"models": [
"040",
@@ -47,69 +164,6 @@
"port": 554,
"url": "/Streaming/Channels/1"
},
{
"models": [
"ALL",
"B220",
"C6T",
"D110",
"DS-H216Q",
"DS-I102",
"DS-I113",
"DS-I114",
"DS-I114W",
"DS-i126",
"ds-i200",
"DS-I200(D)",
"ds-i203",
"DS-I213",
"ds-i214",
"DS-I214(B)",
"ds-i214w(b)",
"ds-i223",
"DS-I400(C)",
"ds-l122",
"ds-n241w",
"i100",
"i110",
"I114",
"i114w",
"I120",
"IPC-B120-I",
"IPC-B140",
"IPC-B622-G2/ZS",
"IPC-D082-G2/S",
"IPC-D120",
"IPC-T640-Z",
"l110",
"Other",
"VDP-D2201",
"VDP-D2211W(B)",
"watch"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/101"
},
{
"models": [
"ALL",
"DS-I102",
"ds-i200",
"Ds-i203",
"DS-I214(B)",
"DS-I214W(B)",
"DS-I253",
"ds-i458",
"HiWatch DS-N208(C)",
"i450s"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/101"
},
{
"models": [
"DC-I200",
@@ -221,16 +275,6 @@
"port": 554,
"url": "/h264_stream"
},
{
"models": [
"ds-i200",
"VDP-D2201"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 555,
"url": "/Streaming/Channels/102"
},
{
"models": [
"Ds-i203"
@@ -240,16 +284,6 @@
"port": 8000,
"url": "/"
},
{
"models": [
"DS-I214(B)",
"DS-I405"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/102"
},
{
"models": [
"DS-I220",
@@ -310,6 +344,89 @@
"protocol": "rtsp",
"port": 554,
"url": "/onvif1"
},
{
"models": [
"ALL",
"B220",
"C6T",
"D110",
"DS-H216Q",
"DS-I102",
"DS-I113",
"DS-I114",
"DS-I114W",
"DS-i126",
"ds-i200",
"DS-I200(D)",
"ds-i203",
"DS-I213",
"ds-i214",
"DS-I214(B)",
"ds-i214w(b)",
"ds-i223",
"DS-I400(C)",
"ds-l122",
"ds-n241w",
"i100",
"i110",
"I114",
"i114w",
"I120",
"IPC-B120-I",
"IPC-B140",
"IPC-B622-G2/ZS",
"IPC-D082-G2/S",
"IPC-D120",
"IPC-T640-Z",
"l110",
"Other",
"VDP-D2201",
"VDP-D2211W(B)",
"watch"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/101"
},
{
"models": [
"ALL",
"DS-I102",
"ds-i200",
"Ds-i203",
"DS-I214(B)",
"DS-I214W(B)",
"DS-I253",
"ds-i458",
"HiWatch DS-N208(C)",
"i450s"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/101"
},
{
"models": [
"ds-i200",
"VDP-D2201"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 555,
"url": "/Streaming/Channels/102"
},
{
"models": [
"DS-I214(B)",
"DS-I405"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/102"
}
]
}
+27 -9
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"0010/0020",
@@ -647,15 +665,6 @@
"port": 0,
"url": "cam[CHANNEL]/h264"
},
{
"models": [
"FCS-3084"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
},
{
"models": [
"FCS-4051",
@@ -770,6 +779,15 @@
"protocol": "http",
"port": 80,
"url": "/cgi-bin/video.jpg"
},
{
"models": [
"FCS-3084"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
}
]
}
+36 -18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"0v600-365-kd",
@@ -153,24 +171,6 @@
"port": 0,
"url": "snapshot.jpg?account=[USERNAME]&password=[PASSWORD]"
},
{
"models": [
"BTD2",
"CAM2",
"DVR-FTD4-8",
"DVR-THD30B",
"FTD4",
"Other",
"WM-CAM-WAWNP2L",
"wmvr-wnip2",
"WNIP2-CM",
"WNIP-2lta-bs"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/301"
},
{
"models": [
"CAM-1",
@@ -375,6 +375,24 @@
"protocol": "http",
"port": 80,
"url": "/cgi-bin/snapshot.cgi?chn=0&u=[USERNAME]&p=[PASSWORD]"
},
{
"models": [
"BTD2",
"CAM2",
"DVR-FTD4-8",
"DVR-THD30B",
"FTD4",
"Other",
"WM-CAM-WAWNP2L",
"wmvr-wnip2",
"WNIP2-CM",
"WNIP-2lta-bs"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/301"
}
]
}
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 10554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 10554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"801",
+128 -92
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"005FTCD",
@@ -588,58 +624,6 @@
"port": 554,
"url": "/ch05/1"
},
{
"models": [
"7-12",
"8ch 3MP NVR",
"dv8-3425",
"DVR w/ Web Port",
"DVR W/ WEB PORT",
"DVR4 4350",
"DVR8",
"DVR8-4900",
"DVR8-8050",
"DVR8-8075",
"HDR8050",
"lv-9808",
"NHD-850CAM",
"NHH-880CAM",
"nvr16-7090",
"NVR-7200",
"Other",
"SWIFI-FLOCAM2",
"swifi-spotcam",
"SWIFI-XTRCAM",
"SWWHD-OUTCAM",
"T855"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"880",
"DVR4 4350",
"DVR8-1500",
"DVR8-1525",
"DVR8-4500",
"DVR8-4900",
"HDR8050",
"lv-9808",
"NHD-850CAM",
"nvr16-7090",
"NVR-7200",
"Other",
"SPOTCAM",
"WIFI-PT"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
},
{
"models": [
"887"
@@ -874,37 +858,6 @@
"port": 0,
"url": "/Streaming/Unicast/channels/401"
},
{
"models": [
"DVR W/ WEB PORT",
"DVR4 4350",
"DVR8-8075",
"lv-9808",
"Other"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/channels/101"
},
{
"models": [
"DVR-1500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/701"
},
{
"models": [
"DVR-1500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/601"
},
{
"models": [
"DVR4",
@@ -942,15 +895,6 @@
"port": 80,
"url": "/?action=stream"
},
{
"models": [
"DVR8-4500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/301"
},
{
"models": [
"DVR8-4500",
@@ -1314,6 +1258,98 @@
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/2"
},
{
"models": [
"7-12",
"8ch 3MP NVR",
"dv8-3425",
"DVR w/ Web Port",
"DVR W/ WEB PORT",
"DVR4 4350",
"DVR8",
"DVR8-4900",
"DVR8-8050",
"DVR8-8075",
"HDR8050",
"lv-9808",
"NHD-850CAM",
"NHH-880CAM",
"nvr16-7090",
"NVR-7200",
"Other",
"SWIFI-FLOCAM2",
"swifi-spotcam",
"SWIFI-XTRCAM",
"SWWHD-OUTCAM",
"T855"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"880",
"DVR4 4350",
"DVR8-1500",
"DVR8-1525",
"DVR8-4500",
"DVR8-4900",
"HDR8050",
"lv-9808",
"NHD-850CAM",
"nvr16-7090",
"NVR-7200",
"Other",
"SPOTCAM",
"WIFI-PT"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
},
{
"models": [
"DVR W/ WEB PORT",
"DVR4 4350",
"DVR8-8075",
"lv-9808",
"Other"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/channels/101"
},
{
"models": [
"DVR-1500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/701"
},
{
"models": [
"DVR-1500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/601"
},
{
"models": [
"DVR8-4500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/301"
}
]
}
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"TBK-BUL8841Z"
+48
View File
@@ -31,6 +31,54 @@
"notes": "Common RTSP sub stream for ONVIF cameras",
"model_count": 9998
},
{
"url": "/Streaming/Channels/[CHANNEL+1]01",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision main stream - 0-based channel input (channel 0 -> 101, 1 -> 201)",
"model_count": 9500
},
{
"url": "/Streaming/Channels/[CHANNEL]01",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision main stream - 1-based channel input (channel 1 -> 101, 2 -> 201)",
"model_count": 9490
},
{
"url": "/Streaming/Channels/[CHANNEL+1]02",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision sub stream - 0-based channel input (channel 0 -> 102, 1 -> 202)",
"model_count": 9480
},
{
"url": "/Streaming/Channels/[CHANNEL]02",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision sub stream - 1-based channel input (channel 1 -> 102, 2 -> 202)",
"model_count": 9470
},
{
"url": "/Streaming/Channels/[CHANNEL+1]03",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision third stream - 0-based channel input (channel 0 -> 103, 1 -> 203)",
"model_count": 9460
},
{
"url": "/Streaming/Channels/[CHANNEL]03",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision third stream - 1-based channel input (channel 1 -> 103, 2 -> 203)",
"model_count": 9450
},
{
"url": "/ch2",
"type": "FFMPEG",
+1 -4
View File
@@ -9,13 +9,10 @@ services:
image: eduard256/strix:latest
container_name: strix
restart: unless-stopped
ports:
- "4567:4567"
network_mode: host
environment:
- STRIX_LOG_LEVEL=info
- STRIX_LOG_FORMAT=json
networks:
- cameras
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4567/api/v1/health"]
interval: 30s
+1 -3
View File
@@ -7,9 +7,7 @@ services:
# build: .
container_name: strix
restart: unless-stopped
ports:
- "4567:4567"
network_mode: host
environment:
# Logging configuration
+13 -4
View File
@@ -302,11 +302,13 @@ func (s *Scanner) collectStreams(ctx context.Context, req models.StreamDiscovery
"model", req.Model,
"limit", req.ModelLimit)
// Search for similar models
cameras, err := s.searchEngine.SearchByModel(req.Model, 0.8, req.ModelLimit)
// Search for cameras using intelligent brand+model search
searchResp, err := s.searchEngine.Search(req.Model, req.ModelLimit)
if err != nil {
s.logger.Error("model search failed", err)
} else {
cameras := searchResp.Cameras
// Collect entries from all matching cameras
var entries []models.CameraEntry
for _, camera := range cameras {
@@ -409,7 +411,14 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
defer cancelProgress()
go func() {
ticker := time.NewTicker(1 * time.Second)
// Use longer interval for Ingress mode to reduce traffic (padding is ~64KB per event)
// Normal mode: 1 second, Ingress mode: 3 seconds
progressInterval := 1 * time.Second
if streamWriter.IsIngress() {
progressInterval = 3 * time.Second
}
ticker := time.NewTicker(progressInterval)
defer ticker.Stop()
for {
@@ -417,7 +426,7 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
case <-progressCtx.Done():
return
case <-ticker.C:
// Send progress every second to prevent WriteTimeout
// Send progress to prevent WriteTimeout and show scanning activity
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
Tested: int(atomic.LoadInt32(&tested)),
Found: int(atomic.LoadInt32(&found)),
+6 -2
View File
@@ -176,8 +176,12 @@ 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),
"[CHANNEL+1]": strconv.Itoa(ctx.Channel + 1), // For Hikvision-style channels (101, 201, 301...)
"[channel+1]": strconv.Itoa(ctx.Channel + 1),
"{CHANNEL}": strconv.Itoa(ctx.Channel), // BUBBLE protocol uses {channel}
"{channel}": strconv.Itoa(ctx.Channel),
"{CHANNEL+1}": strconv.Itoa(ctx.Channel + 1),
"{channel+1}": strconv.Itoa(ctx.Channel + 1),
"[WIDTH]": strconv.Itoa(ctx.Width),
"[width]": strconv.Itoa(ctx.Width),
"[HEIGHT]": strconv.Itoa(ctx.Height),
+63 -1
View File
@@ -5,9 +5,20 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
const (
// IngressPaddingSize is the padding size for Home Assistant Ingress mode.
// HA Supervisor uses aiohttp with 64KB buffer for StreamResponse.
// We need to fill this buffer to force immediate delivery of SSE events.
IngressPaddingSize = 64 * 1024 // 64KB
// IngressHeader is the header that Home Assistant Ingress adds to requests
IngressHeader = "X-Ingress-Path"
)
// Event represents a Server-Sent Event
type Event struct {
ID string
@@ -255,6 +266,7 @@ func generateClientID() string {
type StreamWriter struct {
client *Client
server *Server
isIngress bool // True when running through Home Assistant Ingress proxy
}
// NewStreamWriter creates a new stream writer for a client
@@ -275,6 +287,9 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
// Send initial flush to establish connection
flusher.Flush()
// Detect Home Assistant Ingress mode by checking for X-Ingress-Path header
isIngress := r.Header.Get(IngressHeader) != ""
// Create client
ctx, cancel := context.WithCancel(r.Context())
client := &Client{
@@ -289,6 +304,7 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
return &StreamWriter{
client: client,
server: s,
isIngress: isIngress,
}, nil
}
@@ -304,7 +320,48 @@ func (sw *StreamWriter) SendEvent(eventType string, data interface{}) error {
return fmt.Errorf("response does not support flushing")
}
return sw.server.writeEvent(sw.client.Response, flusher, event)
// Use Ingress-aware write method
return sw.writeEventWithIngress(sw.client.Response, flusher, event)
}
// writeEventWithIngress writes an event and adds padding for Ingress mode
func (sw *StreamWriter) writeEventWithIngress(w http.ResponseWriter, flusher http.Flusher, event Event) error {
// Write the event using standard method
if err := sw.server.writeEvent(w, flusher, event); err != nil {
return err
}
// In Ingress mode, add padding to fill the 64KB buffer and force immediate delivery
if sw.isIngress {
if err := sw.writePadding(w, flusher); err != nil {
return err
}
}
return nil
}
// writePadding writes SSE comment padding to fill proxy buffers.
// SSE comments (lines starting with ':') are ignored by clients.
func (sw *StreamWriter) writePadding(w http.ResponseWriter, flusher http.Flusher) error {
// Create padding using SSE comments which are ignored by clients
// Each line is ": " + padding content + "\n"
// We need ~64KB to fill the aiohttp StreamResponse buffer
const lineSize = 1024 // 1KB per line
const numLines = 64 // 64 lines = 64KB
paddingLine := ": " + strings.Repeat(".", lineSize-4) + "\n" // -4 for ": " and "\n"
for i := 0; i < numLines; i++ {
if _, err := fmt.Fprint(w, paddingLine); err != nil {
return err
}
}
// Flush the padding
flusher.Flush()
return nil
}
// SendJSON sends JSON data as an event
@@ -312,6 +369,11 @@ func (sw *StreamWriter) SendJSON(eventType string, v interface{}) error {
return sw.SendEvent(eventType, v)
}
// IsIngress returns true if running through Home Assistant Ingress proxy
func (sw *StreamWriter) IsIngress() bool {
return sw.isIngress
}
// SendMessage sends a simple message
func (sw *StreamWriter) SendMessage(message string) error {
return sw.SendEvent("message", map[string]string{"message": message})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "webui",
"version": "1.0.4",
"version": "1.0.9",
"type": "module",
"description": "",
"main": "index.js",
+146 -1
View File
@@ -590,10 +590,155 @@ body {
.streams-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
gap: var(--space-6);
padding: var(--space-2);
}
/* ===== STREAM GROUPS ===== */
.stream-group {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.stream-group-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
user-select: none;
transition: color var(--transition-fast);
}
.stream-group-header:hover {
color: var(--purple-primary);
}
.stream-group-toggle {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-tertiary);
transition: all var(--transition-fast);
}
.stream-group-toggle .chevron {
transition: transform var(--transition-fast);
}
.stream-group.collapsed .stream-group-toggle .chevron {
transform: rotate(-90deg);
}
.stream-group.collapsed .stream-group-content {
display: none;
}
.stream-group-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stream-group-count {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-tertiary);
}
.stream-group-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.stream-group-empty {
padding: var(--space-4);
text-align: center;
color: var(--text-tertiary);
font-size: var(--text-sm);
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
border-radius: 8px;
}
/* ===== STREAM SUBGROUPS (Main/Sub/Other within Recommended) ===== */
.stream-subgroup {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.stream-subgroup:not(:last-child) {
margin-bottom: var(--space-4);
}
.stream-subgroup-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding-left: var(--space-2);
cursor: pointer;
user-select: none;
transition: color var(--transition-fast);
}
.stream-subgroup-header:hover {
color: var(--purple-primary);
}
.stream-subgroup-toggle {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-tertiary);
transition: all var(--transition-fast);
}
.stream-subgroup-toggle .chevron {
transition: transform var(--transition-fast);
}
.stream-subgroup.collapsed .stream-subgroup-toggle .chevron {
transform: rotate(-90deg);
}
.stream-subgroup.collapsed .stream-subgroup-content {
display: none;
}
.stream-subgroup-title {
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stream-subgroup-count {
font-size: var(--text-xs);
font-weight: 400;
color: var(--text-disabled);
}
.stream-subgroup-content {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Custom scrollbar */
.streams-list::-webkit-scrollbar {
width: 8px;
@@ -252,6 +252,15 @@ export class FrigateGenerator {
return lines;
}
/**
* Build RTSP path with optional ?mp4 suffix for BUBBLE streams
*/
static buildRtspPath(streamName, streamType) {
const basePath = `rtsp://127.0.0.1:8554/${streamName}`;
// Add ?mp4 parameter only for BUBBLE streams to enable recording in Frigate
return streamType === 'BUBBLE' ? `${basePath}?mp4` : basePath;
}
/**
* Generate camera lines for cameras section
*/
@@ -264,11 +273,14 @@ export class FrigateGenerator {
if (cameraInfo.subStream) {
// Use sub for detect, main for record
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.subStreamName}`);
const subPath = this.buildRtspPath(cameraInfo.subStreamName, cameraInfo.subStream.type);
const mainPath = this.buildRtspPath(cameraInfo.mainStreamName, cameraInfo.mainStream.type);
lines.push(` - path: ${subPath}`);
lines.push(' input_args: preset-rtsp-restream');
lines.push(' roles:');
lines.push(' - detect');
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`);
lines.push(` - path: ${mainPath}`);
lines.push(' input_args: preset-rtsp-restream');
lines.push(' roles:');
lines.push(' - record');
@@ -280,7 +292,9 @@ export class FrigateGenerator {
lines.push(` Sub Stream: ${cameraInfo.subStreamName} # Низкое разрешение (опционально)`);
} else {
// Use main for both detect and record
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`);
const mainPath = this.buildRtspPath(cameraInfo.mainStreamName, cameraInfo.mainStream.type);
lines.push(` - path: ${mainPath}`);
lines.push(' input_args: preset-rtsp-restream');
lines.push(' roles:');
lines.push(' - detect');
+12 -1
View File
@@ -315,6 +315,11 @@ class StrixApp {
document.getElementById('progress-text').textContent = 'Starting scan...';
document.getElementById('streams-section').classList.add('hidden');
this.currentStreams = [];
// Reset stream list state for fresh discovery
this.streamList.selectionMode = 'main';
this.streamList.collapsedGroups.clear();
this.streamList.collapsedSubgroups.clear();
this.streamList.needsSmartDefaults = true;
}
handleProgress(data) {
@@ -334,7 +339,7 @@ class StrixApp {
streamsSection.classList.remove('hidden');
}
// Update stream list
// Update stream list (smart defaults applied automatically on first render)
this.streamList.render(this.currentStreams, (stream, index) => {
this.selectStream(stream, index);
});
@@ -391,6 +396,12 @@ class StrixApp {
document.getElementById('frigate-output-section').classList.add('hidden');
document.getElementById('config-frigate').textContent = '';
// Set stream list to sub selection mode (will collapse Main, show Sub)
this.streamList.setSelectionMode('sub');
this.streamList.render(this.currentStreams, (stream, index) => {
this.selectStream(stream, index);
});
showToast('Select a sub stream from available streams');
this.showScreen('discovery');
}
+207 -41
View File
@@ -2,6 +2,7 @@
export class MockStreamAPI {
constructor() {
this.mockStreams = [
// RTSP Main streams (1920x1080)
{
url: "rtsp://192.168.1.100:554/Streaming/Channels/101",
path: "/Streaming/Channels/101",
@@ -12,6 +13,27 @@ export class MockStreamAPI {
bitrate: 4096000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/live/main",
path: "/live/main",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.264",
fps: 30,
bitrate: 4608000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/stream1",
path: "/stream1",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.265",
fps: 25,
bitrate: 3584000,
has_audio: true
},
// JPEG snapshots (5 items in different positions)
{
url: "http://192.168.1.100/snap.jpg",
path: "/snap.jpg",
@@ -22,16 +44,124 @@ export class MockStreamAPI {
bitrate: 0,
has_audio: false
},
// RTSP Sub streams (640x480)
{
url: "rtsp://192.168.1.100:554/Streaming/Channels/102",
path: "/Streaming/Channels/102",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 5,
bitrate: 512000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/live/sub",
path: "/live/sub",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 10,
bitrate: 768000,
has_audio: false
},
// JPEG #2
{
url: "http://192.168.1.100/cgi-bin/snapshot.cgi",
path: "/cgi-bin/snapshot.cgi",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
bitrate: 0,
has_audio: false
},
{
url: "rtsp://192.168.1.100:554/stream2",
path: "/stream2",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 15,
bitrate: 640000,
has_audio: false
},
// ONVIF streams
{
url: "rtsp://192.168.1.100:554/onvif/profile0",
path: "/onvif/profile0",
type: "ONVIF",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
bitrate: 4096000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/onvif/profile1",
path: "/onvif/profile1",
type: "ONVIF",
resolution: "640x480",
codec: "H.264",
fps: 15,
bitrate: 512000,
has_audio: false
},
// JPEG #3
{
url: "http://192.168.1.100/image/jpeg.cgi",
path: "/image/jpeg.cgi",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
bitrate: 0,
has_audio: false
},
// More RTSP variants
{
url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
path: "/cam/realmonitor?channel=1&subtype=0",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.265",
fps: 30,
bitrate: 5120000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=1",
path: "/cam/realmonitor?channel=1&subtype=1",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 10,
bitrate: 512000,
has_audio: false
},
// MJPEG
{
url: "http://192.168.1.100/video.mjpg",
path: "/video.mjpg",
type: "MJPEG",
resolution: "1280x720",
resolution: "1920x1080",
codec: "MJPEG",
fps: 10,
bitrate: 2048000,
bitrate: 3072000,
has_audio: false
},
// JPEG #4
{
url: "http://192.168.1.100/Streaming/channels/1/picture",
path: "/Streaming/channels/1/picture",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
bitrate: 0,
has_audio: false
},
// HLS
{
url: "http://192.168.1.100/stream/live.m3u8",
path: "/stream/live.m3u8",
@@ -42,16 +172,18 @@ export class MockStreamAPI {
bitrate: 3072000,
has_audio: true
},
// HTTP Video
{
url: "http://192.168.1.100/videostream.cgi?user=admin&pwd=12345",
path: "/videostream.cgi?user=admin&pwd=12345",
type: "HTTP_VIDEO",
resolution: "1280x960",
resolution: "1920x1080",
codec: "H.264",
fps: 20,
bitrate: 2048000,
bitrate: 2560000,
has_audio: false
},
// BUBBLE
{
url: "bubble://192.168.1.100:34567/bubble/live?ch=0&stream=0",
path: "/bubble/live?ch=0&stream=0",
@@ -59,33 +191,75 @@ export class MockStreamAPI {
resolution: "1920x1080",
codec: "H.264",
fps: 25,
bitrate: 3072000,
bitrate: 3584000,
has_audio: true
},
// JPEG #5
{
url: "http://192.168.1.100/tmpfs/auto.jpg",
path: "/tmpfs/auto.jpg",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
bitrate: 0,
has_audio: false
},
// Additional RTSP
{
url: "rtsp://192.168.1.100:554/h264_stream",
path: "/h264_stream",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.264",
fps: 30,
bitrate: 4096000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
path: "/cam/realmonitor?channel=1&subtype=0",
type: "ONVIF",
resolution: "2560x1440",
url: "rtsp://192.168.1.100:554/av0_0",
path: "/av0_0",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
bitrate: 3840000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/av0_1",
path: "/av0_1",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 10,
bitrate: 512000,
has_audio: false
},
{
url: "rtsp://192.168.1.100:554/unicast/c1/s0/live",
path: "/unicast/c1/s0/live",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.265",
fps: 30,
bitrate: 6144000,
fps: 25,
bitrate: 4608000,
has_audio: true
}
];
}
discover(request, callbacks) {
const totalToScan = 150;
const totalToScan = 450;
const streamsToFind = this.mockStreams;
let tested = 0;
let found = 0;
const startTime = Date.now();
// Simulate progressive discovery
const interval = setInterval(() => {
const increment = Math.floor(Math.random() * 8) + 3;
// Simulate progressive discovery - 1 stream per second
const progressInterval = setInterval(() => {
const increment = Math.floor(Math.random() * 15) + 10;
tested = Math.min(tested + increment, totalToScan);
const remaining = totalToScan - tested;
@@ -98,33 +272,9 @@ export class MockStreamAPI {
});
}
// Randomly find streams
if (found < streamsToFind.length && Math.random() > 0.6) {
const stream = streamsToFind[found];
found++;
if (callbacks.onStreamFound) {
callbacks.onStreamFound({
stream: stream
});
}
}
// Complete when done
if (tested >= totalToScan) {
clearInterval(interval);
// Send any remaining streams
while (found < streamsToFind.length) {
const stream = streamsToFind[found];
found++;
if (callbacks.onStreamFound) {
callbacks.onStreamFound({
stream: stream
});
}
}
clearInterval(progressInterval);
const duration = (Date.now() - startTime) / 1000;
@@ -136,7 +286,23 @@ export class MockStreamAPI {
});
}
}
}, 400);
}, 300);
// Find streams at ~1 per second
const streamInterval = setInterval(() => {
if (found < streamsToFind.length) {
const stream = streamsToFind[found];
found++;
if (callbacks.onStreamFound) {
callbacks.onStreamFound({
stream: stream
});
}
} else {
clearInterval(streamInterval);
}
}, 1000);
}
close() {
+300 -3
View File
@@ -4,19 +4,277 @@ export class StreamList {
this.streams = [];
this.onUseCallback = null;
this.expandedIndex = null;
// Track collapsed state for groups and subgroups
this.collapsedGroups = new Set();
this.collapsedSubgroups = new Set();
// Selection mode: 'main' or 'sub'
this.selectionMode = 'main';
// Flag to apply smart defaults on first render after reset
this.needsSmartDefaults = true;
}
/**
* Set selection mode and apply smart defaults for collapsed state
* Only resets collapsed state when mode actually changes
*/
setSelectionMode(mode) {
if (this.selectionMode === mode) return;
this.selectionMode = mode;
this.applySmartDefaults();
}
/**
* Apply smart collapsed defaults based on current selection mode and available streams
*/
applySmartDefaults() {
// Get current stream classification
const recommended = this.streams.filter(s => this.isRecommended(s));
const { main, sub, other } = this.classifyRecommendedStreams(
recommended.map((stream, i) => ({ stream, index: i }))
);
// Reset all collapsed states
this.collapsedGroups.clear();
this.collapsedSubgroups.clear();
if (this.selectionMode === 'main') {
// Main mode: show Main, collapse Sub/Other/Alternative
if (main.length > 0) {
// Has main streams - collapse everything except Main
this.collapsedGroups.add('alternative');
this.collapsedSubgroups.add('recommended-sub');
this.collapsedSubgroups.add('recommended-other');
}
// If no main streams - leave everything open
} else {
// Sub mode: show Sub, collapse Main/Other/Alternative
if (sub.length > 0) {
// Has sub streams - collapse everything except Sub
this.collapsedGroups.add('alternative');
this.collapsedSubgroups.add('recommended-main');
this.collapsedSubgroups.add('recommended-other');
}
// If no sub streams - leave everything open
}
}
// Stream types considered "recommended" (standard video streams)
static RECOMMENDED_TYPES = ['FFMPEG', 'ONVIF'];
// Minimum width threshold for Main streams (HD quality)
static MIN_MAIN_WIDTH = 720;
// Minimum gap between resolutions to split Main/Sub
static MIN_GAP_FOR_SPLIT = 400;
isRecommended(stream) {
return StreamList.RECOMMENDED_TYPES.includes(stream.type);
}
/**
* Parse resolution string "1920x1080" to width number
* Returns null if resolution is missing or invalid
*/
parseResolutionWidth(resolution) {
if (!resolution) return null;
const match = resolution.match(/^(\d+)x(\d+)$/);
if (!match) return null;
return parseInt(match[1], 10);
}
/**
* Classify recommended streams into Main/Sub/Other using clustering algorithm
*
* Algorithm:
* 1. Streams with width >= 720 are candidates for Main
* 2. Streams with width < 720 go to Sub
* 3. Streams without resolution go to Other
* 4. Among Main candidates, find max gap between sorted resolutions
* 5. If gap > 400px, split into Main (higher) and Sub (lower)
*/
classifyRecommendedStreams(items) {
const main = [];
const sub = [];
const other = [];
// First pass: separate by resolution availability and threshold
const mainCandidates = []; // width >= 720
items.forEach(item => {
const width = this.parseResolutionWidth(item.stream.resolution);
if (width === null) {
other.push(item);
} else if (width < StreamList.MIN_MAIN_WIDTH) {
sub.push(item);
} else {
mainCandidates.push({ ...item, width });
}
});
// If no main candidates or only one, no need to cluster
if (mainCandidates.length <= 1) {
mainCandidates.forEach(item => main.push({ stream: item.stream, index: item.index }));
return { main, sub, other };
}
// Sort candidates by width descending
mainCandidates.sort((a, b) => b.width - a.width);
// Find the largest gap between adjacent resolutions
let maxGap = 0;
let splitIndex = -1;
for (let i = 0; i < mainCandidates.length - 1; i++) {
const gap = mainCandidates[i].width - mainCandidates[i + 1].width;
if (gap > maxGap) {
maxGap = gap;
splitIndex = i;
}
}
// If max gap is significant, split into Main and Sub
if (maxGap > StreamList.MIN_GAP_FOR_SPLIT && splitIndex >= 0) {
mainCandidates.forEach((item, i) => {
const cleanItem = { stream: item.stream, index: item.index };
if (i <= splitIndex) {
main.push(cleanItem);
} else {
sub.push(cleanItem);
}
});
} else {
// All candidates stay in Main
mainCandidates.forEach(item => {
main.push({ stream: item.stream, index: item.index });
});
}
return { main, sub, other };
}
render(streams, onUseCallback) {
this.streams = streams;
this.onUseCallback = onUseCallback;
// Render stream items
this.listContainer.innerHTML = streams.map((stream, index) => this.renderItem(stream, index)).join('');
// Apply smart defaults on first render after reset
if (this.needsSmartDefaults && streams.length > 0) {
this.needsSmartDefaults = false;
this.applySmartDefaults();
}
// Split streams into groups while preserving original indices
const recommended = [];
const alternative = [];
streams.forEach((stream, index) => {
if (this.isRecommended(stream)) {
recommended.push({ stream, index });
} else {
alternative.push({ stream, index });
}
});
// Render only non-empty groups
let html = '';
if (recommended.length > 0) {
html += this.renderRecommendedGroup(recommended);
}
if (alternative.length > 0) {
html += this.renderGroup('Alternative', alternative, 'alternative');
}
this.listContainer.innerHTML = html;
// Attach event listeners
this.attachEventListeners();
}
/**
* Render Recommended group with Main/Sub/Other subgroups
*/
renderRecommendedGroup(items) {
const { main, sub, other } = this.classifyRecommendedStreams(items);
const totalCount = items.length;
const isCollapsed = this.collapsedGroups.has('recommended');
let subgroupsHtml = '';
if (main.length > 0) {
subgroupsHtml += this.renderSubgroup('Main', main, 'recommended');
}
if (sub.length > 0) {
subgroupsHtml += this.renderSubgroup('Sub', sub, 'recommended');
}
if (other.length > 0) {
subgroupsHtml += this.renderSubgroup('Other', other, 'recommended');
}
return `
<div class="stream-group stream-group-recommended ${isCollapsed ? 'collapsed' : ''}">
<div class="stream-group-header" data-group="recommended">
<button class="stream-group-toggle" aria-label="Toggle group">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<span class="stream-group-title">Recommended</span>
<span class="stream-group-count">(${totalCount})</span>
</div>
<div class="stream-group-content">
${subgroupsHtml}
</div>
</div>
`;
}
/**
* Render a subgroup (Main/Sub/Other) within Recommended
*/
renderSubgroup(title, items, parentGroup) {
const subgroupKey = `${parentGroup}-${title.toLowerCase()}`;
const isCollapsed = this.collapsedSubgroups.has(subgroupKey);
return `
<div class="stream-subgroup ${isCollapsed ? 'collapsed' : ''}" data-subgroup="${subgroupKey}">
<div class="stream-subgroup-header" data-subgroup="${subgroupKey}">
<button class="stream-subgroup-toggle" aria-label="Toggle subgroup">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" class="chevron">
<path d="M2.5 3.75l2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<span class="stream-subgroup-title">${title}</span>
<span class="stream-subgroup-count">(${items.length})</span>
</div>
<div class="stream-subgroup-content">
${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')}
</div>
</div>
`;
}
renderGroup(title, items, groupClass) {
const count = items.length;
const isCollapsed = this.collapsedGroups.has(groupClass);
return `
<div class="stream-group stream-group-${groupClass} ${isCollapsed ? 'collapsed' : ''}">
<div class="stream-group-header" data-group="${groupClass}">
<button class="stream-group-toggle" aria-label="Toggle group">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<span class="stream-group-title">${title}</span>
<span class="stream-group-count">(${count})</span>
</div>
<div class="stream-group-content">
${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')}
</div>
</div>
`;
}
renderItem(stream, index) {
const icon = this.getStreamIcon(stream.type);
const isExpanded = this.expandedIndex === index;
@@ -225,7 +483,28 @@ export class StreamList {
}
attachEventListeners() {
// Click on header to toggle
// Group header toggle (Recommended, Alternative)
this.listContainer.querySelectorAll('.stream-group-header').forEach(header => {
header.addEventListener('click', (e) => {
const groupKey = header.dataset.group;
if (groupKey) {
this.toggleGroup(groupKey);
}
});
});
// Subgroup header toggle (Main, Sub, Other)
this.listContainer.querySelectorAll('.stream-subgroup-header').forEach(header => {
header.addEventListener('click', (e) => {
e.stopPropagation(); // Don't bubble to group header
const subgroupKey = header.dataset.subgroup;
if (subgroupKey) {
this.toggleSubgroup(subgroupKey);
}
});
});
// Click on stream item header to toggle details
this.listContainer.querySelectorAll('.stream-item-header').forEach(header => {
header.addEventListener('click', (e) => {
// Don't toggle if clicking "Use Stream" button
@@ -250,6 +529,24 @@ export class StreamList {
});
}
toggleGroup(groupKey) {
if (this.collapsedGroups.has(groupKey)) {
this.collapsedGroups.delete(groupKey);
} else {
this.collapsedGroups.add(groupKey);
}
this.render(this.streams, this.onUseCallback);
}
toggleSubgroup(subgroupKey) {
if (this.collapsedSubgroups.has(subgroupKey)) {
this.collapsedSubgroups.delete(subgroupKey);
} else {
this.collapsedSubgroups.add(subgroupKey);
}
this.render(this.streams, this.onUseCallback);
}
toggleExpand(index) {
if (this.expandedIndex === index) {
// Collapse if already expanded