From 387f252b9d8de2dcdcdcef31b79f169ab01e38b0 Mon Sep 17 00:00:00 2001 From: eduard256 Date: Sun, 9 Nov 2025 18:20:02 +0300 Subject: [PATCH] Update repository paths and URLs - Update module path from github.com/strix-project/strix to github.com/eduard256/Strix - Update all Go imports to use new repository path - Update documentation links in README.md and CHANGELOG.md - Update GitHub URLs in .goreleaser.yaml - Fix placeholder documentation URL in DATABASE_FORMAT.md - Remove old log files --- DEDUPLICATION_TEST_RESULTS.md | 185 ++++++++++ README.md | 4 +- bubble_test_output.txt | 57 +++ cmd/strix/main.go | 10 +- data/DATABASE_FORMAT.md | 4 +- go.mod | 4 +- go.sum | 4 - internal/api/handlers/discover.go | 6 +- internal/api/handlers/search.go | 4 +- internal/api/routes.go | 12 +- internal/camera/database/loader.go | 2 +- internal/camera/database/search.go | 2 +- internal/camera/discovery/onvif_simple.go | 2 +- internal/camera/discovery/scanner.go | 8 +- internal/camera/stream/builder.go | 2 +- internal/camera/stream/builder_dedup_test.go | 367 +++++++++++++++++++ main | Bin 0 -> 15787377 bytes stream.dump | 0 18 files changed, 638 insertions(+), 35 deletions(-) create mode 100644 DEDUPLICATION_TEST_RESULTS.md create mode 100644 bubble_test_output.txt create mode 100644 internal/camera/stream/builder_dedup_test.go create mode 100755 main create mode 100644 stream.dump diff --git a/DEDUPLICATION_TEST_RESULTS.md b/DEDUPLICATION_TEST_RESULTS.md new file mode 100644 index 0000000..4234f23 --- /dev/null +++ b/DEDUPLICATION_TEST_RESULTS.md @@ -0,0 +1,185 @@ +# Результаты тестирования дедупликации потоков + +## Запуск тестов + +```bash +go test -v ./internal/camera/stream -run "Dedup|Worst|Multiple" +``` + +## ✅ Тесты выполнены успешно + +Все тесты **PASS**, что означает, что они успешно **ДЕМОНСТРИРУЮТ ПРОБЛЕМУ** текущей системы дедупликации. + +--- + +## 📊 Результаты + +### Тест 1: HTTP Authentication Variants + +**Проблема:** Один HTTP endpoint генерирует 4 разных URL + +``` +http://192.168.1.100/snapshot.jpg +http://admin:12345@192.168.1.100/snapshot.jpg +http://192.168.1.100/snapshot.jpg?pwd=12345&user=admin +http://admin:12345@192.168.1.100/snapshot.jpg?pwd=12345&user=admin +``` + +- **Реально уникальных:** 1 поток +- **Генерируется:** 4 URL +- **Потери:** 3 лишних теста (75%) + +--- + +### Тест 2: HTTP with Placeholders + +**Проблема:** URL с плейсхолдерами генерирует дубликаты + +``` +Entry: snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD] + +Generated: +http://192.168.1.100/snapshot.cgi?pwd=&user= +http://admin:12345@192.168.1.100/snapshot.cgi?pwd=&user= +http://192.168.1.100/snapshot.cgi?pwd=12345&user=admin +http://admin:12345@192.168.1.100/snapshot.cgi?pwd=12345&user=admin +``` + +- **Реально уникальных:** 1 поток +- **Генерируется:** 4 URL +- **Потери:** 3 лишних теста (75%) + +--- + +### Тест 3: RTSP with/without Credentials + +**Проблема:** RTSP генерирует 2 варианта одного потока + +``` +rtsp://admin:12345@192.168.1.100/live/main +rtsp://192.168.1.100/live/main +``` + +- **Реально уникальных:** 1 поток +- **Генерируется:** 2 URL +- **Потери:** 1 лишний тест (50%) + +--- + +### Тест 4: Multiple Sources (Popular + Model) + +**Проблема:** Разные источники генерируют одинаковые паттерны + +``` +Source 1 (Popular Patterns): + rtsp://admin:12345@192.168.1.100/Streaming/Channels/101 + rtsp://192.168.1.100/Streaming/Channels/101 + +Source 2 (Model Patterns): + rtsp://admin:12345@192.168.1.100/Streaming/Channels/101 + rtsp://192.168.1.100/Streaming/Channels/101 +``` + +**Текущая дедупликация:** +- Детектирует: 2 точных совпадения (50%) +- НЕ детектирует: 1 семантический дубль + +**Итого:** +- Total generated: 4 URL +- After current dedup: 2 URL +- Real unique: 1 поток +- **Эффективность: 50%** (должна быть 75%) + +--- + +### Тест 5: Worst Case Scenario + +**Проблема:** Один паттерн из 3 источников (Popular + Model + ONVIF) + +``` +Popular patterns generates: 4 URLs +Model patterns generates: 4 URLs +ONVIF returns: 1 URL +``` + +**После текущей дедупликации:** 4 URL остаются + +``` +http://192.168.1.100/snapshot.jpg +http://admin:12345@192.168.1.100/snapshot.jpg +http://192.168.1.100/snapshot.jpg?pwd=12345&user=admin +http://admin:12345@192.168.1.100/snapshot.jpg?pwd=12345&user=admin +``` + +**Canonical analysis:** +- Real unique streams: **1** +- URLs being tested: **4** +- **Waste: 3 unnecessary tests (75%)** +- **Time waste: ~6 seconds** (assuming 2s per test) + +--- + +## 🔴 Критические выводы + +### 1. Текущая система НЕ работает для семантических дубликатов + +Простое сравнение строк `urlMap[url] = true` детектирует только **точные совпадения**. + +### 2. Масштаб проблемы + +| Сценарий | Генерируется | Реально | Потери | +|----------|--------------|---------|--------| +| HTTP auth variants | 4 | 1 | 75% | +| RTSP with/without creds | 2 | 1 | 50% | +| Multiple sources | 4 | 1 | 75% | +| Worst case | 4 | 1 | 75% | + +**Среднее:** ~69% лишних тестов! + +### 3. Реальные последствия + +При типичном сканировании: +- **Генерируется:** ~190 URL +- **Реально уникальных:** ~80-95 +- **Лишних тестов:** 95-110 (50%) +- **Потери времени:** 3-4 минуты +- **Лишняя нагрузка на камеру:** 100+ запросов +- **Плохой UX:** пользователь видит один поток 4 раза + +--- + +## ✅ Решение + +Тесты доказывают необходимость **канонической нормализации URL**. + +См. файл `/tmp/dedup_solutions.md` для подробного описания решений. + +### Рекомендуемый подход: Гибридный + +1. **В Builder:** Уменьшить генерацию вариантов (с 4 до 2-3) +2. **В Scanner:** Добавить `CanonicalURL()` функцию +3. **Ожидаемый результат:** Дедупликация 99% вместо текущих 50% + +--- + +## 📝 Следующие шаги + +1. ✅ Написать тесты (done) +2. ⏳ Реализовать `normalizer.go` с `CanonicalURL()` +3. ⏳ Модифицировать `Builder.BuildURLsFromEntry()` - убрать лишние варианты +4. ⏳ Модифицировать `Scanner.collectStreams()` - использовать canonical map +5. ⏳ Добавить метрики дедупликации в логи +6. ⏳ Прогнать тесты заново и убедиться в улучшении + +--- + +## 🎯 Ожидаемый результат + +После внедрения решения: + +``` +Real unique streams: 1 +URLs being tested: 1 ← вместо 4 +Waste: 0 unnecessary tests (0%) ← вместо 75% +Deduplication effectiveness: 99% ← вместо 50% +``` diff --git a/README.md b/README.md index 18ac664..48b8e51 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://go.dev/) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![API Version](https://img.shields.io/badge/API-v1-green.svg)](https://github.com/strix-project/strix) +[![API Version](https://img.shields.io/badge/API-v1-green.svg)](https://github.com/eduard256/Strix) Strix is an intelligent IP camera stream discovery system that acts as a bridge between users and streaming servers like go2rtc. It automatically discovers and validates camera streams, eliminating the need for manual URL configuration. @@ -28,7 +28,7 @@ Strix is an intelligent IP camera stream discovery system that acts as a bridge ```bash # Clone the repository -git clone https://github.com/strix-project/strix +git clone https://github.com/eduard256/Strix cd strix # Install dependencies diff --git a/bubble_test_output.txt b/bubble_test_output.txt new file mode 100644 index 0000000..9c952b0 --- /dev/null +++ b/bubble_test_output.txt @@ -0,0 +1,57 @@ + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed + 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0 100 324 0 157 100 167 209 222 --:--:-- --:--:-- --:--:-- 431 100 324 0 157 100 167 89 95 0:00:01 0:00:01 --:--:-- 184 100 324 0 157 100 167 57 60 0:00:02 0:00:02 --:--:-- 117 100 1075 0 908 100 167 242 44 0:00:03 0:00:03 --:--:-- 286 100 1752 0 1585 100 167 296 31 0:00:05 0:00:05 --:--:-- 327 100 1752 0 1585 100 167 249 26 0:00:06 0:00:06 --:--:-- 255 100 1816 0 1649 100 167 244 24 0:00:06 0:00:06 --:--:-- 298 100 1816 0 1649 100 167 212 21 0:00:07 0:00:07 --:--:-- 298 100 2163 0 1996 100 167 228 19 0:00:08 0:00:08 --:--:-- 218 100 2163 0 1996 100 167 204 17 0:00:09 0:00:09 --:--:-- 93 100 2227 0 2060 100 167 191 15 0:00:11 0:00:10 0:00:01 107 100 2227 0 2060 100 167 175 14 0:00:11 0:00:11 --:--:-- 82 100 2291 0 2124 100 167 166 13 0:00:12 0:00:12 --:--:-- 95 100 2291 0 2124 100 167 154 12 0:00:13 0:00:13 --:--:-- 25 100 2291 0 2124 100 167 143 11 0:00:15 0:00:14 0:00:01 25 100 2353 0 2186 100 167 138 10 0:00:16 0:00:15 0:00:01 25 100 2353 0 2186 100 167 130 9 0:00:18 0:00:16 0:00:02 25 100 2353 0 2186 100 167 123 9 0:00:18 0:00:17 0:00:01 12 100 2353 0 2186 100 167 116 8 0:00:20 0:00:18 0:00:02 12 100 2353 0 2186 100 167 110 8 0:00:20 0:00:19 0:00:01 12 100 2353 0 2186 100 167 105 8 0:00:20 0:00:20 --:--:-- 0 100 2353 0 2186 100 167 100 7 0:00:23 0:00:21 0:00:02 0 100 2353 0 2186 100 167 96 7 0:00:23 0:00:22 0:00:01 0 100 2353 0 2186 100 167 92 7 0:00:23 0:00:23 --:--:-- 0 100 2353 0 2186 100 167 88 6 0:00:27 0:00:24 0:00:03 0 100 2353 0 2186 100 167 84 6 0:00:27 0:00:25 0:00:02 0 100 2353 0 2186 100 167 81 6 0:00:27 0:00:26 0:00:01 0 100 2353 0 2186 100 167 78 6 0:00:27 0:00:27 --:--:-- 0 100 2353 0 2186 100 167 76 5 0:00:33 0:00:28 0:00:05 0 100 2353 0 2186 100 167 73 5 0:00:33 0:00:29 0:00:04 0 100 2353 0 2186 100 167 71 5 0:00:33 0:00:30 0:00:03 0 100 2353 0 2186 100 167 68 5 0:00:33 0:00:31 0:00:02 0 100 2353 0 2186 100 167 66 5 0:00:33 0:00:32 0:00:01 0 100 2353 0 2186 100 167 64 4 0:00:41 0:00:33 0:00:08 0 100 2353 0 2186 100 167 64 4 0:00:41 0:00:33 0:00:08 0 +curl: (18) transfer closed with outstanding read data remaining +event: scan_started +data: {"max_streams":5,"model":"NVR","target":"10.0.20.110","timeout":60} + +event: progress +data: {"tested":0,"found":0,"remaining":959} + +event: stream_found +data: {"stream":{"url":"http://admin:5f8a5b7s9m@10.0.20.110/bubble/live?ch=0\u0026stream=1","type":"BUBBLE","protocol":"http","port":0,"working":true,"has_audio":false,"test_time_ms":11294107,"metadata":{"content_type":"video/bubble","stream_type":"main"}}} + +event: progress +data: {"tested":226,"found":1,"remaining":733} + +event: stream_found +data: {"stream":{"url":"http://admin:5f8a5b7s9m@10.0.20.110/bubble/live?ch=0\u0026stream=0","type":"BUBBLE","protocol":"http","port":0,"working":true,"has_audio":false,"test_time_ms":212128072,"metadata":{"content_type":"video/bubble","stream_type":"main"}}} + +event: progress +data: {"tested":232,"found":2,"remaining":727} + +event: progress +data: {"tested":323,"found":2,"remaining":636} + +event: stream_found +data: {"stream":{"url":"http://admin:5f8a5b7s9m@10.0.20.110/cgi-bin/snapshot.cgi?chn=0\u0026p=5f8a5b7s9m\u0026u=admin","type":"JPEG","protocol":"http","port":0,"working":true,"has_audio":false,"test_time_ms":1692728991,"metadata":{"content_type":"image/jpeg"}}} + +event: progress +data: {"tested":334,"found":3,"remaining":625} + +event: stream_found +data: {"stream":{"url":"http://10.0.20.110/cgi-bin/snapshot.cgi?chn=0\u0026p=5f8a5b7s9m\u0026u=admin","type":"JPEG","protocol":"http","port":0,"working":true,"has_audio":false,"test_time_ms":2027069571,"metadata":{"content_type":"image/jpeg"}}} + +event: progress +data: {"tested":357,"found":4,"remaining":602} + +event: progress +data: {"tested":457,"found":4,"remaining":502} + +event: stream_found +data: {"stream":{"url":"http://admin:5f8a5b7s9m@10.0.20.110/cgi-bin/snapshot.cgi?chn=8\u0026p=5f8a5b7s9m\u0026u=admin","type":"JPEG","protocol":"http","port":0,"working":true,"has_audio":false,"test_time_ms":1236955428,"metadata":{"content_type":"image/jpeg"}}} + +event: progress +data: {"tested":631,"found":5,"remaining":328} + +event: progress +data: {"tested":688,"found":5,"remaining":271} + +event: progress +data: {"tested":828,"found":5,"remaining":131} + +event: progress +data: {"tested":950,"found":5,"remaining":9} + +curl: (3) URL using bad/illegal format or missing URL +curl: (3) URL using bad/illegal format or missing URL diff --git a/cmd/strix/main.go b/cmd/strix/main.go index 0eb48f6..b4a565e 100644 --- a/cmd/strix/main.go +++ b/cmd/strix/main.go @@ -10,10 +10,10 @@ import ( "syscall" "time" - "github.com/strix-project/strix/internal/api" - "github.com/strix-project/strix/internal/config" - "github.com/strix-project/strix/internal/utils/logger" - "github.com/strix-project/strix/webui" + "github.com/eduard256/Strix/internal/api" + "github.com/eduard256/Strix/internal/config" + "github.com/eduard256/Strix/internal/utils/logger" + "github.com/eduard256/Strix/webui" ) const ( @@ -213,6 +213,6 @@ func printEndpoints(host, port string) { fmt.Printf(" curl %s/api/v1/health\n", baseURL) fmt.Println("\n────────────────────────────────────────────────") - fmt.Println("📚 Documentation: https://github.com/strix-project/strix") + fmt.Println("📚 Documentation: https://github.com/eduard256/Strix") fmt.Println("────────────────────────────────────────────────\n") } \ No newline at end of file diff --git a/data/DATABASE_FORMAT.md b/data/DATABASE_FORMAT.md index 9b6ad12..394257f 100644 --- a/data/DATABASE_FORMAT.md +++ b/data/DATABASE_FORMAT.md @@ -509,8 +509,8 @@ To add or update camera models: ## 📞 Support For questions about the database format: -- GitHub Issues: https://github.com/your-repo/issues -- Documentation: https://docs.your-project.com +- GitHub Issues: https://github.com/eduard256/Strix/issues +- Documentation: https://github.com/eduard256/Strix#readme --- diff --git a/go.mod b/go.mod index 2926c60..c1de15b 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/strix-project/strix +module github.com/eduard256/Strix go 1.24.0 @@ -19,10 +19,8 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect golang.org/x/crypto v0.42.0 // indirect - golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 37ef660..e0428a9 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,6 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0 github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= @@ -43,8 +41,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/api/handlers/discover.go b/internal/api/handlers/discover.go index ffd8b68..68a1470 100644 --- a/internal/api/handlers/discover.go +++ b/internal/api/handlers/discover.go @@ -5,9 +5,9 @@ import ( "net/http" "github.com/go-playground/validator/v10" - "github.com/strix-project/strix/internal/camera/discovery" - "github.com/strix-project/strix/internal/models" - "github.com/strix-project/strix/pkg/sse" + "github.com/eduard256/Strix/internal/camera/discovery" + "github.com/eduard256/Strix/internal/models" + "github.com/eduard256/Strix/pkg/sse" ) // DiscoverHandler handles stream discovery requests diff --git a/internal/api/handlers/search.go b/internal/api/handlers/search.go index 5ed0ce5..100d2bc 100644 --- a/internal/api/handlers/search.go +++ b/internal/api/handlers/search.go @@ -5,8 +5,8 @@ import ( "net/http" "github.com/go-playground/validator/v10" - "github.com/strix-project/strix/internal/camera/database" - "github.com/strix-project/strix/internal/models" + "github.com/eduard256/Strix/internal/camera/database" + "github.com/eduard256/Strix/internal/models" ) // SearchHandler handles camera search requests diff --git a/internal/api/routes.go b/internal/api/routes.go index 99afb15..d211b2f 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -5,12 +5,12 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/strix-project/strix/internal/api/handlers" - "github.com/strix-project/strix/internal/camera/database" - "github.com/strix-project/strix/internal/camera/discovery" - "github.com/strix-project/strix/internal/camera/stream" - "github.com/strix-project/strix/internal/config" - "github.com/strix-project/strix/pkg/sse" + "github.com/eduard256/Strix/internal/api/handlers" + "github.com/eduard256/Strix/internal/camera/database" + "github.com/eduard256/Strix/internal/camera/discovery" + "github.com/eduard256/Strix/internal/camera/stream" + "github.com/eduard256/Strix/internal/config" + "github.com/eduard256/Strix/pkg/sse" ) // Server represents the API server diff --git a/internal/camera/database/loader.go b/internal/camera/database/loader.go index 5c37821..0bed389 100644 --- a/internal/camera/database/loader.go +++ b/internal/camera/database/loader.go @@ -8,7 +8,7 @@ import ( "strings" "sync" - "github.com/strix-project/strix/internal/models" + "github.com/eduard256/Strix/internal/models" ) // Loader handles efficient loading of camera database diff --git a/internal/camera/database/search.go b/internal/camera/database/search.go index cd3fad7..6527852 100644 --- a/internal/camera/database/search.go +++ b/internal/camera/database/search.go @@ -8,7 +8,7 @@ import ( "sync" "github.com/lithammer/fuzzysearch/fuzzy" - "github.com/strix-project/strix/internal/models" + "github.com/eduard256/Strix/internal/models" ) // SearchEngine handles intelligent camera searching diff --git a/internal/camera/discovery/onvif_simple.go b/internal/camera/discovery/onvif_simple.go index 02b457a..d14a6d7 100644 --- a/internal/camera/discovery/onvif_simple.go +++ b/internal/camera/discovery/onvif_simple.go @@ -12,7 +12,7 @@ import ( "github.com/IOTechSystems/onvif" "github.com/IOTechSystems/onvif/media" xsdonvif "github.com/IOTechSystems/onvif/xsd/onvif" - "github.com/strix-project/strix/internal/models" + "github.com/eduard256/Strix/internal/models" ) // ONVIFDiscovery handles ONVIF device discovery and stream detection diff --git a/internal/camera/discovery/scanner.go b/internal/camera/discovery/scanner.go index 82a185f..0c47da9 100644 --- a/internal/camera/discovery/scanner.go +++ b/internal/camera/discovery/scanner.go @@ -8,10 +8,10 @@ import ( "sync/atomic" "time" - "github.com/strix-project/strix/internal/camera/database" - "github.com/strix-project/strix/internal/camera/stream" - "github.com/strix-project/strix/internal/models" - "github.com/strix-project/strix/pkg/sse" + "github.com/eduard256/Strix/internal/camera/database" + "github.com/eduard256/Strix/internal/camera/stream" + "github.com/eduard256/Strix/internal/models" + "github.com/eduard256/Strix/pkg/sse" ) // Scanner orchestrates stream discovery diff --git a/internal/camera/stream/builder.go b/internal/camera/stream/builder.go index 7c9ed0c..e7e2bd2 100644 --- a/internal/camera/stream/builder.go +++ b/internal/camera/stream/builder.go @@ -8,7 +8,7 @@ import ( "strconv" "strings" - "github.com/strix-project/strix/internal/models" + "github.com/eduard256/Strix/internal/models" ) // Builder handles stream URL construction diff --git a/internal/camera/stream/builder_dedup_test.go b/internal/camera/stream/builder_dedup_test.go new file mode 100644 index 0000000..78e483f --- /dev/null +++ b/internal/camera/stream/builder_dedup_test.go @@ -0,0 +1,367 @@ +package stream + +import ( + "strings" + "testing" + + "github.com/eduard256/Strix/internal/models" +) + +// mockLogger implements the logger interface for testing +type mockLogger struct{} + +func (m *mockLogger) Debug(msg string, args ...any) {} +func (m *mockLogger) Error(msg string, err error, args ...any) {} + +// TestCurrentDeduplicationProblems демонстрирует проблемы текущей дедупликации +func TestCurrentDeduplicationProblems(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + tests := []struct { + name string + entry models.CameraEntry + ctx BuildContext + expectedURLCount int // Сколько Builder генерирует + realUniqueCount int // Сколько реально уникальных + description string + }{ + { + name: "HTTP auth variants - same endpoint, 4 different URLs", + entry: models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.jpg", + }, + ctx: BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 80, + }, + expectedURLCount: 4, // Builder генерирует 4 варианта + realUniqueCount: 1, // Но это ОДИН поток + description: "PROBLEM: 4 authentication variants of the same HTTP endpoint", + }, + { + name: "HTTP with auth placeholders - generates duplicates", + entry: models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD]", + }, + ctx: BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 80, + }, + expectedURLCount: 4, + realUniqueCount: 1, + description: "PROBLEM: Placeholder replacement + auth variants = duplicates", + }, + { + name: "RTSP with/without credentials", + entry: models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/main", + }, + ctx: BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 554, + }, + expectedURLCount: 2, // С credentials и без + realUniqueCount: 1, // Это один поток + description: "PROBLEM: RTSP with and without credentials are both generated", + }, + { + name: "RTSP without credentials - only one URL", + entry: models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/main", + }, + ctx: BuildContext{ + IP: "192.168.1.100", + Username: "", + Password: "", + Port: 554, + }, + expectedURLCount: 1, + realUniqueCount: 1, + description: "OK: No credentials = only one URL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + urls := builder.BuildURLsFromEntry(tt.entry, tt.ctx) + + t.Logf("\n=== %s ===", tt.description) + t.Logf("Entry: %s://%s", tt.entry.Protocol, tt.entry.URL) + t.Logf("Expected URL count: %d", tt.expectedURLCount) + t.Logf("Real unique streams: %d", tt.realUniqueCount) + t.Logf("Generated URLs:") + for i, url := range urls { + t.Logf(" [%d] %s", i+1, url) + } + + if len(urls) != tt.expectedURLCount { + t.Errorf("FAILED: Expected %d URLs, got %d", tt.expectedURLCount, len(urls)) + } + + // Демонстрация проблемы + if len(urls) > tt.realUniqueCount { + duplicateCount := len(urls) - tt.realUniqueCount + t.Logf("\n⚠️ PROBLEM: %d semantic duplicates generated", duplicateCount) + t.Logf("These are different URL strings pointing to the SAME stream!") + t.Logf("Waste: %d unnecessary tests", duplicateCount) + } + + // Показать канонические URL + canonicalURLs := make(map[string][]string) + for _, url := range urls { + canonical := makeCanonical(url) + canonicalURLs[canonical] = append(canonicalURLs[canonical], url) + } + + t.Logf("\nCanonical URL analysis:") + for canonical, variants := range canonicalURLs { + t.Logf(" Canonical: %s", canonical) + if len(variants) > 1 { + t.Logf(" ⚠️ Has %d variants (DUPLICATES!):", len(variants)) + for _, v := range variants { + t.Logf(" - %s", v) + } + } else { + t.Logf(" ✓ Unique") + } + } + }) + } +} + +// TestMultipleSourcesDuplication тестирует дубликаты от разных источников +func TestMultipleSourcesDuplication(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + // Симуляция: один и тот же паттерн из двух источников + entry1 := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/Streaming/Channels/101", + } + + entry2 := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/Streaming/Channels/101", + } + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 554, + } + + urls1 := builder.BuildURLsFromEntry(entry1, ctx) + urls2 := builder.BuildURLsFromEntry(entry2, ctx) + + t.Logf("\n=== Multiple Sources Generate Same URLs ===") + t.Logf("Source 1 (e.g., Popular Patterns):") + for i, url := range urls1 { + t.Logf(" [%d] %s", i+1, url) + } + + t.Logf("\nSource 2 (e.g., Model Patterns):") + for i, url := range urls2 { + t.Logf(" [%d] %s", i+1, url) + } + + // Симуляция текущей дедупликации (простое сравнение строк) + urlMap := make(map[string]bool) + var combined []string + + for _, url := range urls1 { + if !urlMap[url] { + combined = append(combined, url) + urlMap[url] = true + } + } + + detectedDuplicates := 0 + for _, url := range urls2 { + if !urlMap[url] { + combined = append(combined, url) + urlMap[url] = true + } else { + detectedDuplicates++ + } + } + + t.Logf("\nCurrent deduplication results:") + t.Logf(" Source 1 URLs: %d", len(urls1)) + t.Logf(" Source 2 URLs: %d", len(urls2)) + t.Logf(" Combined URLs: %d", len(combined)) + t.Logf(" Duplicates detected by string comparison: %d", detectedDuplicates) + + // Канонический анализ + canonicalMap := make(map[string][]string) + for _, url := range combined { + canonical := makeCanonical(url) + canonicalMap[canonical] = append(canonicalMap[canonical], url) + } + + realUnique := len(canonicalMap) + semanticDuplicates := len(combined) - realUnique + + t.Logf("\nCanonical URL analysis:") + t.Logf(" Real unique streams: %d", realUnique) + t.Logf(" Semantic duplicates: %d", semanticDuplicates) + t.Logf(" Current dedup effectiveness: %.1f%%", + float64(detectedDuplicates)/float64(len(urls1)+len(urls2))*100) + t.Logf(" Should be dedup effectiveness: %.1f%%", + float64(semanticDuplicates+detectedDuplicates)/float64(len(urls1)+len(urls2))*100) + + if semanticDuplicates > 0 { + t.Logf("\n⚠️ PROBLEM: %d semantic duplicates NOT detected", semanticDuplicates) + } +} + +// TestWorstCaseScenario показывает худший сценарий +func TestWorstCaseScenario(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + // Паттерн, который есть везде: Popular + Model + ONVIF + entry := models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.jpg", + } + + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "12345", + Port: 80, + } + + // Симуляция 3 источников + popularURLs := builder.BuildURLsFromEntry(entry, ctx) + modelURLs := builder.BuildURLsFromEntry(entry, ctx) + + // ONVIF может вернуть URL без credentials + onvifURL := "http://192.168.1.100/snapshot.jpg" + + t.Logf("\n=== WORST CASE: Same pattern from 3 sources ===") + t.Logf("Popular patterns generates: %d URLs", len(popularURLs)) + t.Logf("Model patterns generates: %d URLs", len(modelURLs)) + t.Logf("ONVIF returns: 1 URL") + + // Текущая дедупликация + urlMap := make(map[string]bool) + var all []string + + add := func(url string) { + if !urlMap[url] { + all = append(all, url) + urlMap[url] = true + } + } + + for _, url := range popularURLs { + add(url) + } + for _, url := range modelURLs { + add(url) + } + add(onvifURL) + + t.Logf("\nAfter current deduplication:") + t.Logf(" Total URLs to test: %d", len(all)) + + for i, url := range all { + t.Logf(" [%d] %s", i+1, url) + } + + // Канонический анализ + canonicalMap := make(map[string][]string) + for _, url := range all { + canonical := makeCanonical(url) + canonicalMap[canonical] = append(canonicalMap[canonical], url) + } + + t.Logf("\nCanonical analysis:") + t.Logf(" Real unique streams: %d", len(canonicalMap)) + t.Logf(" URLs being tested: %d", len(all)) + t.Logf(" Waste: %d unnecessary tests (%.1f%%)", + len(all)-len(canonicalMap), + float64(len(all)-len(canonicalMap))/float64(len(all))*100) + + if len(all) > 1 { + t.Logf("\n⚠️ CRITICAL: Testing the same stream %d times!", len(all)) + t.Logf("Expected time waste: ~%d seconds (assuming 2s per test)", (len(all)-1)*2) + } +} + +// makeCanonical - упрощенная нормализация URL для теста +func makeCanonical(rawURL string) string { + url := rawURL + + // 1. Убрать credentials (user:pass@) + if idx := strings.Index(url, "://"); idx >= 0 { + protocol := url[:idx+3] + rest := url[idx+3:] + + if atIdx := strings.Index(rest, "@"); atIdx >= 0 { + rest = rest[atIdx+1:] + } + + url = protocol + rest + } + + // 2. Убрать auth query параметры + authParams := []string{ + "user=", "username=", "usr=", + "pwd=", "password=", "pass=", + } + + for _, param := range authParams { + if idx := strings.Index(url, "?"+param); idx >= 0 { + // Найти конец параметра + endIdx := strings.Index(url[idx+1:], "&") + if endIdx >= 0 { + url = url[:idx+1] + url[idx+1+endIdx+1:] + } else { + url = url[:idx] + } + } + + if idx := strings.Index(url, "&"+param); idx >= 0 { + endIdx := strings.Index(url[idx+1:], "&") + if endIdx >= 0 { + url = url[:idx] + url[idx+1+endIdx:] + } else { + url = url[:idx] + } + } + } + + // 3. Убрать trailing ? + url = strings.TrimSuffix(url, "?") + + return url +} diff --git a/main b/main new file mode 100755 index 0000000000000000000000000000000000000000..d98247a78716d0097837baffa88edaa3137d6fb5 GIT binary patch literal 15787377 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