46 Commits

Author SHA1 Message Date
eduard256 0205e32257 Merge develop into main for v1.0.11 release 2026-03-19 11:33:03 +00:00
eduard256 c576b09e8b Release v1.0.11 2026-03-19 11:32:58 +00:00
eduard256 25f1907fc3 Register HEAD route for health endpoint in chi router 2026-03-19 11:25:57 +00:00
eduard256 cc8c3e4f14 Fix health endpoint HEAD method support, add icon assets
- Allow HEAD requests on /api/v1/health for Docker/CasaOS healthcheck
  compatibility (wget --spider sends HEAD)
- Add project icon in SVG, 192x192 PNG and 512x512 PNG formats
2026-03-19 11:17:22 +00:00
eduard256 4bd2de78dc Merge develop into main for v1.0.10 release 2026-03-17 07:27:07 +00:00
eduard256 89a7c87462 Release v1.0.10 2026-03-17 07:26:58 +00:00
eduard256 e40dccbb90 Remove CI/CD, unify Docker image for Docker Hub and HA add-on
- Remove GitHub Actions workflows (ci.yml, docker.yml, release.yml)
- Remove GoReleaser configuration
- Remove RELEASE.md (replaced by /release_strix skill)
- Add HA options.json support in config.go (reads /data/options.json)
- Add Version field to Config, pass real version to health endpoint
- Change Version from const to var, inject via ldflags at build time
- Add ARG VERSION to Dockerfile for build-time version injection
- Reset webui/package.json version to 0.0.0 (not used functionally)
- Clear probe fields on back navigation in frontend
- Add /release_strix and /release_strix_dev skills
2026-03-17 07:23:04 +00:00
eduard256 fe93aa329c Integrate probe endpoint into frontend
- Add ProbeAPI client (js/api/probe.js)
- Add reusable modal component (js/ui/modal.js) with overlay, animations
- Call GET /api/v1/probe after Check Address click
- Auto-fill Camera Model with vendor from ARP/OUI lookup
- Show modal on unreachable device with Change IP / Continue Anyway buttons
- Add modal CSS styles matching existing dark theme
2026-03-16 20:05:00 +00:00
eduard256 ddf2b4a373 Remove experimental SSE warning from Home Assistant Add-on section 2026-03-16 14:56:55 +00:00
eduard256 833da5cf48 Remove experimental SSE warning from Home Assistant Add-on section 2026-03-16 14:55:57 +00:00
eduard256 3fec89be7f Add HTTP prober, optimize mDNS timeout, add Trassir/ZOSI to OUI
- Add HTTPProber: parallel HEAD+GET on ports 80/8080, extracts Server header
- Reduce mDNS timeout from 1s to 100ms using context wrapper around
  mdns.Query (HomeKit devices respond in 2-10ms, no need to wait 1s)
- Add Trassir (F0:23:B9) and ZOSI (00:05:FE) to camera OUI database
- Probe response time improved from ~1s to ~110ms for reachable devices
2026-03-16 14:46:58 +00:00
eduard256 4d6c2fd878 Add GET /api/v1/probe endpoint for device inspection
Fast (~1-3s) endpoint that gathers network info about a device
before full stream discovery. Runs ping first, then parallel probes.

Features:
- Ping with ICMP + TCP fallback (works without root)
- Reverse DNS hostname lookup
- ARP table MAC address + OUI vendor identification (2403 entries, 51 camera vendors)
- mDNS HomeKit detection (camera/doorbell, paired status)
- Extensible Prober interface for adding new probe types
- 3-second overall timeout, parallel execution

Response includes "type" field:
- "unreachable" - device not responding
- "standard" - normal IP camera (RTSP/HTTP/ONVIF flow)
- "homekit" - Apple HomeKit camera (PIN pairing flow)
2026-03-16 13:57:41 +00:00
eduard256 eb8cc546c8 Merge main into develop: Add release documentation 2025-12-11 16:53:02 +00:00
eduard256 1fc345c569 Add release process documentation 2025-12-11 16:52:53 +00:00
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
eduard256 11e6ba9902 Merge develop: Fix SSE timeout issues 2025-11-22 20:35:48 +03:00
eduard256 a6e9cc2c5e Fix SSE timeout issues with long-running stream discovery
Problem:
- WriteTimeout was 30 seconds
- Progress only sent when values changed
- Long ffprobe tests (7-8s each) could cause 30+ seconds without writes
- Result: "curl: (18) transfer closed with outstanding read data remaining"

Solution:
- Increase WriteTimeout from 30s to 5 minutes
- Send progress every 1 second (instead of 3 seconds)
- Always send progress, even if values unchanged
- Guarantees write every second, preventing timeout

Changes:
- internal/config/config.go: WriteTimeout 30s → 5min
- internal/camera/discovery/scanner.go:
  - Progress ticker 3s → 1s
  - Remove "only if changed" check
  - Always send progress to keep connection alive

Testing:
- HiWatch camera with 591 streams: Previously timed out at ~338/591
- Should now complete all 591 streams without timeout
2025-11-22 19:48:03 +03:00
eduard256 12770ed5b9 Merge pull request #1 from eduard256/develop
WebUI Improvements - Mock Mode, Tooltips, and UX Enhancements
2025-11-22 00:38:56 +03:00
eduard256 90c4416709 Add informational tooltips for stream types and update mock data
- Add tooltips for all 7 stream types: FFMPEG, ONVIF, MJPEG, HLS, BUBBLE, JPEG, HTTP_VIDEO
- Each tooltip explains protocol features, use cases, and compatibility
- Add BUBBLE protocol icon and detailed description (XMEye/DVRIP cameras)
- Update mock streams to show one example of each type
- Remove unused mock-data.js file to reduce confusion
- Add CSS styles for stream type info icons
2025-11-22 00:29:07 +03:00
eduard256 d602c8dfca Improve WebUI UX with tooltips, auto-fill and button visibility
- Add informational tooltips to all configuration fields
- Reorder tabs: Frigate first, then Go2RTC, then URL
- Hide Copy/Download buttons on Frigate tab until config is generated
- Auto-fill username field with "admin" as default value
- Smart pre-fill network address based on server IP (first 3 octets)
- Add tooltips for Main Stream, Sub Stream, and all buttons
- Improve user guidance throughout the configuration flow
2025-11-22 00:03:54 +03:00
eduard256 596cf1ccdc Add interactive tooltips to camera configuration form
Добавлены информационные тултипы для всех полей формы настройки камеры с подробными описаниями, примерами использования и рекомендациями. Улучшает пользовательский опыт и помогает пользователям правильно заполнить форму.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 23:37:02 +03:00
eduard256 779ae33bac Redesign stream discovery UI with vertical list layout
- Replace carousel navigation with scrollable vertical list
- Remove statistics counters (Tested/Found/Remaining)
- Add collapsible stream details with expand/collapse toggle
- Show stream URL preview in header, full URL in details
- Position URL below stream type badge for better readability
- Add new StreamList component replacing StreamCarousel
- Update CSS with improved layout and hover effects

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 23:25:30 +03:00
eduard256 71d6f2aac8 Fix missing statistics elements in stream discovery UI
Restored stats block (Tested, Found, Remaining) that was accidentally
removed when adding mock mode functionality. This fixes JavaScript
errors where main.js tried to update non-existent DOM elements.
2025-11-21 23:02:39 +03:00
eduard256 56c06dfa98 Add mock mode for WebUI development
- Add mock API classes for camera search and stream discovery
- Add mock mode toggle via ?mock=true URL parameter
- Add visual mock mode indicator badge
- Add dev-server.sh script for local development
- Mock data includes 10 diverse streams (FFMPEG, ONVIF, JPEG, MJPEG, HLS, HTTP_VIDEO)
2025-11-21 22:57:37 +03:00
eduard256 8bf92e6598 Add mock mode for web UI development and testing
- Add mock data module with simulated camera search and stream discovery
- Enable mock mode via ?mock=true URL parameter
- Show MOCK MODE indicator when enabled
- Remove statistics cards from discovery screen, keep only progress bar
- Mock mode works independently from Go backend for easier UI testing
2025-11-21 22:40:38 +03:00
eduard256 522d274dd4 Fix CPU usage percentage in README 2025-11-20 19:19:59 +03:00
eduard256 8036d3e9be Add GitHub Stars badge to README 2025-11-18 17:03:54 +03:00
eduard256 5b2f80f057 Add Docker and Docker Compose auto-installation to compose command 2025-11-18 16:05:47 +03:00
eduard256 e2b9802fd8 Fix duplicate badges in README
Removed duplicate license and Docker pulls badges from README.
2025-11-18 16:04:23 +03:00
eduard256 65a198d119 Simplify RTSP URL description in README
Updated README to simplify the RTSP URL description and remove redundant lines.
2025-11-18 16:03:24 +03:00
eduard256 722c629c01 Move demo gif after badges for better visual flow 2025-11-18 16:01:30 +03:00
eduard256 c81d9a1e63 Complete README.md rewrite with improved structure and documentation 2025-11-18 15:59:59 +03:00
62 changed files with 8677 additions and 2149 deletions
+169
View File
@@ -0,0 +1,169 @@
---
name: release_strix
description: Full release of Strix -- merge develop to main, tag, build multiarch Docker image, push to Docker Hub, update hassio-strix, create GitHub Release.
disable-model-invocation: true
---
# Strix Release
You are performing a full release of Strix. Follow every step exactly. Do NOT skip steps. Do NOT ask for confirmation except where explicitly noted below.
## Repositories
- Strix: `/home/user/Strix`
- hassio-strix: `/home/user/hassio-strix`
## Step 1: Gather information
```bash
cd /home/user/Strix
git checkout develop
git pull origin develop
git pull origin main
# Get last release tag
git tag --sort=-version:refname | head -1
# Show all commits since last release
git log main..develop --oneline
# Show changed files
git diff main..develop --stat
```
## Step 2: Ask for version (THE ONLY QUESTION)
Use AskUserQuestion to ask the user which version to release.
Show them:
- The last tag
- The list of commits from Step 1
Offer options:
- Next patch (e.g. 1.0.9 -> 1.0.10)
- Next minor (e.g. 1.0.9 -> 1.1.0)
- Next major (e.g. 1.0.9 -> 2.0.0)
- Other (user types custom version)
Wait for answer. Store the chosen version as VERSION (without "v" prefix).
## Step 3: Verify build
```bash
cd /home/user/Strix
go test ./...
go build ./...
```
If tests or build fail -- STOP and report the error. Do not continue.
## Step 4: Update CHANGELOG.md
Read `/home/user/Strix/CHANGELOG.md`. Add a new section at the top (after the header lines), based on the commits from Step 1. Follow the existing format exactly:
```markdown
## [VERSION] - YYYY-MM-DD
### Added
- ...
### Fixed
- ...
### Changed
- ...
```
Use today's date. Categorize commits into Added/Fixed/Changed/Technical sections. Only include sections that have entries. Write clear, user-facing descriptions (not raw commit messages).
## Step 5: Git -- commit, merge, tag, push
```bash
cd /home/user/Strix
git add CHANGELOG.md
git commit -m "Release v$VERSION"
git checkout main
git merge develop --no-ff -m "Merge develop into main for v$VERSION release"
git tag v$VERSION
git push origin main --tags
git checkout develop
git merge main
git push origin develop
```
## Step 6: Build and push Docker image
```bash
cd /home/user/Strix
docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg VERSION=$VERSION \
-t eduard256/strix:$VERSION \
-t eduard256/strix:latest \
-t eduard256/strix:$(echo $VERSION | cut -d. -f1-2) \
-t eduard256/strix:$(echo $VERSION | cut -d. -f1) \
--push .
```
## Step 7: Verify Docker Hub
```bash
curl -s "https://hub.docker.com/v2/repositories/eduard256/strix/tags/?page_size=10" | jq '.results[].name'
docker manifest inspect eduard256/strix:$VERSION | jq '.manifests[].platform'
```
Verify the new version tag exists and both amd64 and arm64 platforms are present.
## Step 8: Smoke test
```bash
docker run --rm -d --name strix-smoke-test -p 14567:4567 eduard256/strix:$VERSION
sleep 5
curl -s http://localhost:14567/api/v1/health | jq '.version'
docker stop strix-smoke-test
```
Verify the health endpoint returns the correct version string.
## Step 9: Update hassio-strix
```bash
cd /home/user/hassio-strix
git pull origin main
```
Edit `/home/user/hassio-strix/strix/config.json` -- change `"version"` to the new VERSION.
Edit `/home/user/hassio-strix/strix/CHANGELOG.md` -- add the same CHANGELOG section as in Step 4.
```bash
cd /home/user/hassio-strix
git add strix/config.json strix/CHANGELOG.md
git commit -m "Release v$VERSION"
git push origin main
```
## Step 10: GitHub Release
```bash
cd /home/user/Strix
PREV_TAG=$(git tag --sort=-version:refname | sed -n '2p')
gh release create v$VERSION \
--title "v$VERSION" \
--notes "$(git log --oneline ${PREV_TAG}..v$VERSION)"
```
## Step 11: Final report
Output a summary:
```
Release v$VERSION complete:
- Git: tag v$VERSION pushed to main
- Docker Hub: eduard256/strix:$VERSION (amd64 + arm64)
- Health check: version "$VERSION" verified
- hassio-strix: config.json updated to $VERSION, pushed to main
- GitHub Release: <URL from gh release create>
```
+64
View File
@@ -0,0 +1,64 @@
---
name: release_strix_dev
description: Build and push dev Docker image for Strix, update hassio-strix dev add-on version.
disable-model-invocation: true
---
# Strix Dev Build
You are building and pushing a dev image of Strix. Follow every step exactly. Do NOT ask any questions -- this is fully automated.
## Repositories
- Strix: `/home/user/Strix`
- hassio-strix: `/home/user/hassio-strix`
## Step 1: Get commit hash
```bash
cd /home/user/Strix
git rev-parse --short HEAD
```
Store this as COMMIT_HASH (e.g. `fe93aa3`).
## Step 2: Build Docker image
```bash
cd /home/user/Strix
docker build --build-arg VERSION=dev-$COMMIT_HASH -t eduard256/strix:dev -t eduard256/strix:dev-$COMMIT_HASH .
```
## Step 3: Push to Docker Hub
```bash
docker push eduard256/strix:dev
docker push eduard256/strix:dev-$COMMIT_HASH
```
## Step 4: Update hassio-strix
```bash
cd /home/user/hassio-strix
git pull origin main
```
Edit `/home/user/hassio-strix/strix-dev/config.json` -- change `"version"` to `dev-$COMMIT_HASH`.
```bash
cd /home/user/hassio-strix
git add strix-dev/config.json
git commit -m "Dev build dev-$COMMIT_HASH"
git push origin main
```
## Step 5: Report
Output a summary:
```
Dev build complete:
- Commit: $COMMIT_HASH
- Docker Hub: eduard256/strix:dev, eduard256/strix:dev-$COMMIT_HASH (amd64)
- hassio-strix: strix-dev version updated to dev-$COMMIT_HASH
```
-54
View File
@@ -1,54 +0,0 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Run tests
run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: Build
run: make build
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
-77
View File
@@ -1,77 +0,0 @@
name: Docker Build and Push
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
env:
REGISTRY: docker.io
IMAGE_NAME: eduard256/strix
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ steps.meta.outputs.version }}
- name: Docker Hub Description
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
repository: ${{ env.IMAGE_NAME }}
readme-filepath: ./README.md
short-description: "Smart IP Camera Stream Discovery System"
-32
View File
@@ -1,32 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-112
View File
@@ -1,112 +0,0 @@
# GoReleaser configuration for Strix
version: 2
before:
hooks:
- go mod tidy
- go mod download
builds:
- id: strix
main: ./cmd/strix/main.go
binary: strix
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
- arm
goarm:
- "7"
ignore:
- goos: windows
goarch: arm
- goos: darwin
goarch: arm
ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.BuildDate={{.Date}}
- -X main.GitCommit={{.ShortCommit}}
archives:
- id: strix-archive
format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
files:
- README.md
- LICENSE
- webui/**/*
- cameras/**/*
checksum:
name_template: 'checksums.txt'
algorithm: sha256
changelog:
sort: asc
use: github
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
- typo
groups:
- title: '🚀 Features'
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
order: 0
- title: '🐛 Bug Fixes'
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
order: 1
- title: '📝 Documentation'
regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$'
order: 2
- title: '🔧 Other'
order: 999
release:
github:
owner: eduard256
name: Strix
draft: false
prerelease: auto
name_template: "v{{.Version}}"
header: |
## 🦉 Strix v{{.Version}}
Smart IP Camera Stream Discovery System
### Installation
Download the appropriate binary for your platform below and extract it.
### Usage
```bash
./strix
```
Then open http://localhost:4567 in your browser.
footer: |
**Full Changelog**: https://github.com/eduard256/Strix/compare/{{ .PreviousTag }}...{{ .Tag }}
snapshot:
name_template: "{{ incpatch .Version }}-next"
dist: dist
+78
View File
@@ -5,6 +5,84 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.11] - 2026-03-19
### Added
- Project icon assets (SVG, 192x192 PNG, 512x512 PNG) for use in app stores and integrations
### Fixed
- Health endpoint now accepts HEAD requests for Docker and CasaOS healthcheck compatibility
- Registered HEAD route in chi router for /api/v1/health endpoint
## [1.0.10] - 2026-03-17
### Added
- Device probe endpoint (GET /api/v1/probe) for network device inspection
- HTTP prober for detecting camera web interfaces
- mDNS discovery for local network devices
- ARP/OUI vendor identification with camera OUI database (2,400+ entries)
- Probe integration into frontend with modal UI
- Added Trassir and ZOSI to OUI database
### Changed
- Removed CI/CD pipelines (GitHub Actions), replaced with local Docker builds
- Removed GoReleaser, unified Docker image for Docker Hub and HA add-on
- Application version now injected at build time via ldflags
- HA add-on reads /data/options.json natively (no more entrypoint script)
- Optimized mDNS discovery timeout
### Fixed
- Removed experimental SSE warning from Home Assistant Add-on documentation
- Clear probe-filled fields when navigating back in frontend
## [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 ## [0.1.0] - 2025-11-06
### Added ### Added
+3 -1
View File
@@ -4,6 +4,8 @@
# Stage 1: Builder # Stage 1: Builder
FROM golang:1.24-alpine AS builder FROM golang:1.24-alpine AS builder
ARG VERSION=dev
WORKDIR /build WORKDIR /build
# Install build dependencies # Install build dependencies
@@ -18,7 +20,7 @@ COPY . .
# Build static binary # Build static binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-ldflags="-s -w -X main.Version=docker" \ -ldflags="-s -w -X main.Version=${VERSION}" \
-o strix \ -o strix \
cmd/strix/main.go cmd/strix/main.go
+510 -177
View File
@@ -1,217 +1,550 @@
# 🦉 Strix - Smart IP Camera Stream Discovery System # Strix
[![GitHub Stars](https://img.shields.io/github/stars/eduard256/strix?style=social)](https://github.com/eduard256/strix/stargazers)
![Strix Demo](assets/main.gif?v=2)
[![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) [![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/eduard256/Strix) [![Docker Pulls](https://img.shields.io/docker/pulls/eduard256/strix)](https://hub.docker.com/r/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. ## Spent 2 years googling URL for your Chinese camera?
## 🎯 Features **Strix finds working streams automatically. In 30 seconds.**
- **Intelligent Camera Search**: Fuzzy search across 3,600+ camera models - **67,288** camera models
- **Automatic Stream Discovery**: ONVIF, database patterns, and popular URL detection - **3,636** brands (from Hikvision to AliExpress no-name)
- **Real-time Updates**: Server-Sent Events (SSE) for live discovery progress - **102,787** URL patterns (RTSP, HTTP, MJPEG, JPEG, BUBBLE)
- **Universal Protocol Support**: RTSP, HTTP, MJPEG, JPEG snapshots, and more
- **Smart URL Building**: Automatic placeholder replacement and authentication handling
- **Concurrent Testing**: Fast parallel stream validation with ffprobe
- **Memory Efficient**: Streaming JSON parsing for large camera databases
- **API-First Design**: RESTful API with comprehensive documentation
## 🚀 Quick Start ![Demo](assets/main.gif)
### Docker (Recommended) ---
## Your Problem?
- ❌ Bought ZOSI NVR, zero documentation
- ❌ Camera has no RTSP, only weird JPEG snapshots
- ❌ Frigate eating 70% CPU
- ❌ Config breaks after adding each camera
- ❌ Don't understand Frigate syntax
## Solution
-**Auto-discovery** - tests 102,787 URL variations in parallel
-**Any protocol** - No RTSP? Finds HTTP MJPEG
-**Config generation** - ready Frigate.yml in 2 minutes
-**Sub/Main streams** - CPU from 30% → 8%
-**Smart merging** - adds camera to existing config with 500+ cameras
---
## 🚀 Installation (One Command)
### Ubuntu / Debian
```bash ```bash
# Using Docker Compose (recommended) 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
docker-compose up -d
# Or using Docker directly
docker run -d \
--name strix \
-p 4567:4567 \
eduard256/strix:latest
# Access at http://localhost:4567
``` ```
See [Docker documentation](DOCKER.md) for more options. Open **http://YOUR_SERVER_IP:4567**
### Build from Source ### Docker Compose
Prerequisites:
- Go 1.21 or higher
- ffprobe (optional, for enhanced stream validation)
```bash ```bash
# Clone the repository sudo apt update && command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh && command -v docker-compose >/dev/null 2>&1 || { sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && sudo chmod +x /usr/local/bin/docker-compose; } && curl -fsSL https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.yml -o docker-compose.yml && docker-compose up -d
git clone https://github.com/eduard256/Strix
cd strix
# Install dependencies
make deps
# Build the application
make build
# Run the application
make run
# The server will start on http://localhost:4567
# Open your browser and navigate to http://localhost:4567
``` ```
## 📡 API Endpoints ### Home Assistant Add-on
**Installation:**
1. Go to **Settings****Add-ons****Add-on Store**
2. Click **⋮** (top right) → **Repositories**
3. Add: `https://github.com/eduard256/hassio-strix`
4. Find **"Strix"** in store
5. Click **Install**
6. Enable **"Start on boot"** and **"Show in sidebar"**
7. Click **Start**
---
## How to Use
### Step 1: Open Web Interface
```
http://YOUR_SERVER_IP:4567
```
### Step 2: Enter Camera Details
- **IP Address**: `192.168.1.100`
- **Username**: `admin` (if required)
- **Password**: your camera password
- **Model**: optional, improves accuracy
### Step 3: Discover Streams
Click **"Discover Streams"**
Watch real-time progress:
- Which URL is being tested
- How many tested
- Found streams appear instantly
Wait 30-60 seconds.
### Step 4: Choose Stream
Strix shows details for each stream:
| Stream | Details |
|--------|---------|
| **Protocol** | RTSP, HTTP, MJPEG, JPEG |
| **Resolution** | 1920x1080, 640x480 |
| **FPS** | 25, 15, 10 |
| **Codec** | H264, H265, MJPEG |
| **Audio** | Yes / No |
### Step 5: Generate Frigate Config
Click **"Use Stream"** → **"Generate Frigate Config"**
You get ready config:
```yaml
go2rtc:
streams:
'192_168_1_100_main':
- http://admin:pass@192.168.1.100:8000/video.mjpg
'192_168_1_100_sub':
- http://admin:pass@192.168.1.100:8000/video2.mjpg
cameras:
camera_192_168_1_100:
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/192_168_1_100_sub
roles: [detect] # CPU 8% instead of 70%
- path: rtsp://127.0.0.1:8554/192_168_1_100_main
roles: [record] # HD recording
objects:
track: [person, car, cat, dog]
record:
enabled: true
```
**Smart Merging:**
- Paste your existing `frigate.yml` with 500 cameras
- Strix adds camera #501 correctly
- Doesn't break structure
- Preserves all settings
### Step 6: Add to Frigate
Copy config → Paste to `frigate.yml` → Restart Frigate
**Done!**
---
## Features
### Exotic Camera Support
90% of Chinese cameras don't have RTSP. Strix supports everything:
- **HTTP MJPEG** - most old cameras
- **JPEG snapshots** - auto-converted to stream via FFmpeg
- **RTSP** - if available
- **HTTP-FLV** - some Chinese brands
- **BUBBLE** - proprietary Chinese NVR/DVR protocol
- **ONVIF** - auto-discovery
### Camera Database
**67,288 models from 3,636 brands:**
- **Known brands**: Hikvision, Dahua, Axis, Foscam, TP-Link
- **Chinese no-names**: ZOSI, Escam, Sricam, Wanscam, Besder
- **AliExpress junk**: cameras without name, OEM models
- **Old systems**: NVR/DVR with proprietary protocols
### Discovery Methods
Strix tries all methods in parallel:
**1. ONVIF** (30% success rate)
- Asks camera directly for stream URLs
- Works for ONVIF-compatible cameras
**2. Database Lookup** (60% success rate)
- 67,288 models with known working URLs
- Brand and model-specific patterns
**3. Popular Patterns** (90% success rate)
- 206 most common URL paths
- Works even for unknown cameras
**Result: Finds stream for 95% of cameras**
### Frigate Config Generation
**What you get:**
**Main/Sub streams**
- Main (HD) for recording
- Sub (low res) for object detection
- CPU usage reduced 5-10x
**Ready go2rtc config**
- Stream multiplexing
- Protocol conversion
- JPEG → RTSP via FFmpeg
**Smart config merging**
- Add to existing config
- Preserve structure
- No manual YAML editing
**Pre-configured detection**
- person, car, cat, dog
- Ready motion recording
- 7 days retention
### Speed
- Tests **20 URLs in parallel**
- Average discovery time: **30-60 seconds**
- Complex cameras: **2-3 minutes**
- Real-time progress updates via SSE
---
## Advanced Configuration
### Docker Environment Variables
```yaml
environment:
- STRIX_API_LISTEN=:8080 # Custom port
- STRIX_LOG_LEVEL=debug # Detailed logs
- STRIX_LOG_FORMAT=json # JSON logging
```
### Config File
Create `strix.yaml`:
```yaml
api:
listen: ":8080"
```
Example: [strix.yaml.example](strix.yaml.example)
### Discovery Parameters
In web UI under **Advanced**:
- **Channel** - for NVR systems (usually 0)
- **Timeout** - max discovery time (default: 240s)
- **Max Streams** - stop after N streams (default: 10)
---
## FAQ
### No streams found?
**Check network:**
```bash
ping 192.168.1.100
```
Camera must be reachable.
**Verify credentials:**
- Username/password correct?
- Try without credentials (some cameras are open)
**Try without model:**
- Strix will run ONVIF + 206 popular patterns
- Works for cameras not in database
### Camera not in database?
**No problem.**
Strix will still find stream via:
1. ONVIF (if supported)
2. 206 popular URL patterns
3. Common ports and paths
4. HTTP MJPEG on various ports
5. JPEG snapshot endpoints
**Help the project:**
- Found working stream? [Create Issue](https://github.com/eduard256/Strix/issues)
- Share model and URL
- We'll add to database
### Found only JPEG snapshots?
**Normal for old cameras.**
Strix auto-converts JPEG to stream via FFmpeg:
```yaml
go2rtc:
streams:
camera_main:
- exec:ffmpeg -loop 1 -framerate 10 -i http://192.168.1.100/snapshot.jpg -c:v libx264 -f rtsp {output}
```
Frigate gets normal 10 FPS stream.
### Stream found but doesn't work in Frigate?
**Try another stream:**
- Strix usually finds 3-10 variants
- Some may need special FFmpeg parameters
**Use sub stream:**
- For object detection
- Less CPU load
- Better performance
### How does config generation work?
**For new config:**
- Strix creates complete `frigate.yml` from scratch
- Includes go2rtc, camera, object detection
**For existing config:**
- Paste your current `frigate.yml`
- Strix adds new camera
- Preserves all existing cameras
- Doesn't break structure
**Main/Sub streams:**
- Main (HD) - for recording
- Sub (low res) - for detection
- CPU savings 5-10x
### Is it safe to enter passwords?
**Yes.**
- Strix runs locally on your network
- Nothing sent to external servers
- Passwords not saved
- Open source - check the code yourself
### Works offline?
**Yes.**
- Database embedded in Docker image
- Internet only needed to download image
- Runs offline after that
---
## API Reference
REST API available for automation:
### Health Check ### Health Check
```bash ```bash
GET /api/v1/health GET /api/v1/health
``` ```
### Camera Search ### Search Cameras
```bash ```bash
POST /api/v1/cameras/search POST /api/v1/cameras/search
{ {
"query": "zosi zg23213m", "query": "hikvision",
"limit": 10 "limit": 10
} }
``` ```
### Stream Discovery (SSE) ### Discover Streams (SSE)
```bash ```bash
POST /api/v1/streams/discover POST /api/v1/streams/discover
{ {
"target": "192.168.1.100", # IP or stream URL "target": "192.168.1.100",
"model": "zosi zg23213m", # Optional camera model "username": "admin",
"username": "admin", # Optional "password": "12345",
"password": "password", # Optional "model": "DS-2CD2xxx",
"timeout": 240, # Seconds (default: 240) "timeout": 240,
"max_streams": 10, # Maximum streams to find "max_streams": 10
"channel": 0 # For NVR systems
} }
``` ```
## 🔍 How It Works Returns Server-Sent Events with real-time progress.
1. **Camera Search**: Intelligent fuzzy matching across brand and model database **Full API documentation:** [DOCKER.md](DOCKER.md)
2. **URL Collection**: Combines ONVIF discovery, model-specific patterns, and popular URLs
3. **Stream Validation**: Concurrent testing using ffprobe and HTTP requests
4. **Real-time Updates**: SSE streams provide instant feedback on discovered streams
5. **Smart Filtering**: Deduplicates URLs and prioritizes working streams
## 📁 Project Structure
```
strix/
├── cmd/strix/ # Application entry point
├── internal/ # Private application code
│ ├── api/ # HTTP handlers and routing
│ ├── camera/ # Camera database and discovery
│ │ ├── database/ # Database loading and search
│ │ ├── discovery/ # ONVIF and stream discovery
│ │ └── stream/ # URL building and validation
│ ├── config/ # Configuration management
│ └── models/ # Data structures
├── pkg/ # Public packages
│ └── sse/ # Server-Sent Events
├── data/ # Camera database (3,600+ models)
│ ├── brands/ # Brand-specific JSON files
│ ├── popular_stream_patterns.json
│ └── query_parameters.json
└── go.mod
```
## 🛠️ Configuration
Strix can be configured via `strix.yaml` file or environment variables.
### Configuration File (strix.yaml)
Create a `strix.yaml` file in the same directory as the binary:
```yaml
# API Server Configuration
api:
listen: ":4567" # Format: ":port" or "host:port"
```
Examples:
```yaml
api:
listen: ":4567" # All interfaces, port 4567 (default)
# listen: "127.0.0.1:4567" # Localhost only
# listen: ":8080" # Custom port
```
### Environment Variables
Environment variables override config file values:
```bash
STRIX_API_LISTEN=":4567" # Server listen address (overrides strix.yaml)
STRIX_LOG_LEVEL=info # Log level: debug, info, warn, error
STRIX_LOG_FORMAT=json # Log format: json, text
```
### Configuration Priority
1. **Environment variable** `STRIX_API_LISTEN` (highest priority)
2. **Config file** `strix.yaml`
3. **Default value** `:4567` (lowest priority)
### Quick Start with Custom Port
```bash
# Using environment variable
STRIX_API_LISTEN=":8080" ./strix
# Or using config file
cp strix.yaml.example strix.yaml
# Edit strix.yaml, then:
./strix
```
## 📊 Camera Database
The system includes a comprehensive database of camera models:
- **3,600+ camera brands**
- **150+ popular stream patterns**
- **258 query parameter variations**
- **Automatic placeholder replacement**
## 🔧 Development
```bash
# Run tests
make test
# Format code
make fmt
# Run linter
make lint
# Build for all platforms
make build-all
# Development mode with live reload
make dev
```
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
## 🙏 Acknowledgments
- Camera database sourced from ispyconnect.com
- Inspired by go2rtc project
- Built with Go and Chi router
--- ---
Made with ❤️ for the home automation community ## Technical Details
### Architecture
- **Language:** Go 1.24
- **Database:** 3,636 JSON files
- **Image size:** 80-90 MB (Alpine Linux)
- **Dependencies:** FFmpeg/FFprobe for validation
- **Concurrency:** Worker pool (20 parallel tests)
- **Real-time:** Server-Sent Events (SSE)
### Build from Source
```bash
git clone https://github.com/eduard256/Strix
cd Strix
make build
./bin/strix
```
**Requirements:**
- Go 1.21+
- FFprobe (optional, for stream validation)
### Docker Platforms
- linux/amd64
- linux/arm64
Auto-built and published to Docker Hub on every push to `main`.
---
## Use Cases
### Home Automation
- Add cheap cameras to Home Assistant
- Integrate with Frigate NVR
- Object detection with low CPU
- Motion recording
### Security Systems
- Discover streams in old NVR systems
- Find backup cameras without docs
- Migrate from proprietary DVR to Frigate
- Reduce hardware requirements
### IP Camera Testing
- Test cameras before deployment
- Verify stream quality
- Find optimal resolution/FPS
- Check codec compatibility
---
## Troubleshooting
### Frigate still eating CPU?
**Use sub stream:**
1. Find both main and sub streams with Strix
2. Generate config with both
3. Sub for detect, main for record
4. CPU drops 5-10x
**Example:**
```yaml
inputs:
- path: rtsp://127.0.0.1:8554/camera_sub # 640x480 for detect
roles: [detect]
- path: rtsp://127.0.0.1:8554/camera_main # 1920x1080 for record
roles: [record]
```
### Can't find specific stream quality?
**In web UI:**
- Strix shows all found streams
- Filter by resolution
- Choose optimal FPS
- Select codec (H264 recommended for Frigate)
### Stream works but no audio in Frigate?
**Check Strix stream details:**
- "Has Audio" field shows if audio present
- Some cameras have video-only streams
- Try different stream URL from Strix results
### Discovery takes too long?
**Reduce search scope:**
- Specify exact camera model (faster database lookup)
- Lower "Max Streams" (stops after N found)
- Reduce timeout (default 240s)
**In Advanced settings:**
```
Max Streams: 5 (instead of 10)
Timeout: 120 (instead of 240)
```
---
## Contributing
### Add Your Camera
Found working stream for camera not in database?
1. [Create Issue](https://github.com/eduard256/Strix/issues)
2. Provide:
- Camera brand and model
- Working URL pattern
- Protocol (RTSP/HTTP/etc)
3. We'll add to database
### Report Bugs
- [GitHub Issues](https://github.com/eduard256/Strix/issues)
- Include logs (set `STRIX_LOG_LEVEL=debug`)
- Camera model and IP (if possible)
### Feature Requests
- [GitHub Discussions](https://github.com/eduard256/Strix/discussions)
- Describe use case
- Explain expected behavior
---
## Credits
- **Camera database:** [ispyconnect.com](https://www.ispyconnect.com)
- **Inspiration:** [go2rtc](https://github.com/AlexxIT/go2rtc) by AlexxIT
- **Community:** Home Assistant, Frigate NVR users
---
## License
MIT License - use commercially, modify, distribute freely.
See [LICENSE](LICENSE) file for details.
---
## Support
- **Issues:** [GitHub Issues](https://github.com/eduard256/Strix/issues)
- **Discussions:** [GitHub Discussions](https://github.com/eduard256/Strix/discussions)
- **Docker:** [Docker Hub](https://hub.docker.com/r/eduard256/strix)
---
**Made for people tired of cameras without documentation**
*Tested on Chinese AliExpress junk that finally works now.*
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" height="512" width="512" viewBox="0 0 512.001 512.001">
<g>
<path style="fill:#7E57C2;" d="M124.477,378.183L9.347,495.779c21.628,21.628,56.695,21.628,78.324,0L119,464.45 c21.628,21.628,56.696,21.628,78.324,0l28.375-28.375C187.373,426.203,151.825,405.106,124.477,378.183z"/>
<path style="fill:#7E57C2;" d="M447.27,55.383h-55.383V177.22c0.002-40.997,22.277-76.788,55.383-95.939 c16.293-9.425,35.207-14.822,55.383-14.822c0-6.982,0-11.077,0-11.077V0C472.065,0,447.27,24.796,447.27,55.383z"/>
</g>
<path style="fill:#9575CD;" d="M336.504,55.383C336.504,24.796,311.708,0,281.121,0v66.46c20.176,0,39.091,5.397,55.383,14.822 c33.107,19.153,55.383,54.946,55.383,95.945V55.383H336.504z"/>
<path style="fill:#E8E0F5;" d="M391.887,209.772v-27.116c0-3.312,0-5.432,0-5.432c0-40.997-22.276-76.791-55.383-95.942 c-16.293-9.425-35.207-14.822-55.383-14.822v155.073C311.213,191.443,357.554,187.532,391.887,209.772z M314.351,143.996 c0-12.234,9.918-22.153,22.153-22.153s22.153,9.919,22.153,22.153c0,12.235-9.918,22.153-22.153,22.153 S314.351,156.231,314.351,143.996z"/>
<path style="fill:#D1C4E9;" d="M391.887,177.221v32.551c5.151,3.336,10.037,7.246,14.55,11.76h96.216V66.46 c-20.176,0-39.091,5.397-55.383,14.822C414.164,100.434,391.888,136.225,391.887,177.221z M469.423,143.996 c0,12.235-9.918,22.153-22.153,22.153s-22.153-9.918-22.153-22.153c0-12.234,9.918-22.153,22.153-22.153 C459.504,121.843,469.423,131.762,469.423,143.996z"/>
<path style="fill:#9575CD;" d="M281.121,221.532l-10.2,10.2L281.121,221.532z"/>
<path style="fill:#B39DDB;" d="M406.438,221.533c34.606,34.606,34.606,90.712,0,125.319c-69.21,69.21-181.422,69.21-250.633,0.002 l-31.329,31.33c27.348,26.923,62.896,48.02,101.221,57.892c17.714,4.562,36.285,6.989,55.423,6.989 c122.349,0,221.532-99.182,221.532-221.531C502.653,221.533,406.437,221.532,406.438,221.533z"/>
<path style="fill:#9575CD;" d="M281.121,221.532l125.318,125.319c34.606-34.606,34.606-90.713,0-125.319 c-4.515-4.514-9.401-8.425-14.551-11.761C357.554,187.532,311.213,191.443,281.121,221.532z"/>
<path style="fill:#7E57C2;" d="M406.438,346.851L281.12,221.533l-10.199,10.2L155.802,346.851 C225.017,416.062,337.228,416.061,406.438,346.851z"/>
<circle style="fill:#9575CD;" cx="336.507" cy="143.996" r="22.153"/>
<circle style="fill:#7E57C2;" cx="447.274" cy="143.996" r="22.153"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+6 -6
View File
@@ -18,12 +18,12 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
const ( // Version is set at build time via ldflags:
// Version is the application version //
Version = "1.0.4" // go build -ldflags="-X main.Version=1.0.10" ./cmd/strix
var Version = "dev"
// Banner is the application banner const Banner = `
Banner = `
███████╗████████╗██████╗ ██╗██╗ ██╗ ███████╗████████╗██████╗ ██╗██╗ ██╗
██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝ ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝
███████╗ ██║ ██████╔╝██║ ╚███╔╝ ███████╗ ██║ ██████╔╝██║ ╚███╔╝
@@ -34,7 +34,6 @@ const (
Smart IP Camera Stream Discovery System Smart IP Camera Stream Discovery System
Version: %s Version: %s
` `
)
func main() { func main() {
// Print banner // Print banner
@@ -43,6 +42,7 @@ func main() {
// Load configuration // Load configuration
cfg := config.Load() cfg := config.Load()
cfg.Version = Version
// Setup logger // Setup logger
slogger := cfg.SetupLogger() slogger := cfg.SetupLogger()
+64 -28
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"3628-675", "3628-675",
@@ -313,15 +349,6 @@
"port": 0, "port": 0,
"url": "" "url": ""
}, },
{
"models": [
"IPC-300"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/101"
},
{ {
"models": [ "models": [
"IPC-340HD", "IPC-340HD",
@@ -465,15 +492,6 @@
"port": 0, "port": 0,
"url": "snapshot.jpg" "url": "snapshot.jpg"
}, },
{
"models": [
"IPC-740"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/102"
},
{ {
"models": [ "models": [
"IP-CAM", "IP-CAM",
@@ -631,16 +649,6 @@
"port": 80, "port": 80,
"url": "/videostream.asf?user=[USERNAME]&pwd=[PASSWORD]&resolution=320x240" "url": "/videostream.asf?user=[USERNAME]&pwd=[PASSWORD]&resolution=320x240"
}, },
{
"models": [
"PX3615",
"SK7008-T1F1"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/401"
},
{ {
"models": [ "models": [
"PX-3615-675" "PX-3615-675"
@@ -722,6 +730,34 @@
"protocol": "http", "protocol": "http",
"port": 82, "port": 82,
"url": "/cgi/mjpg/mjpg.cgi" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"10550", "10550",
@@ -320,31 +356,6 @@
"port": 554, "port": 554,
"url": "/s2" "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": [ "models": [
"IPCB54611B", "IPCB54611B",
@@ -635,6 +646,31 @@
"protocol": "rtsp", "protocol": "rtsp",
"port": 554, "port": 554,
"url": "/mpeg4/media.amp?resolution=640x480" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"WIFI-5MP-30" "WIFI-5MP-30"
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"mega216" "mega216"
+127 -91
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"NVR", "NVR",
@@ -220,55 +256,6 @@
"port": 0, "port": 0,
"url": "snapshot.jpg?user=[USERNAME]&pwd=[PASSWORD]&strm=[CHANNEL]" "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": [ "models": [
"141CS", "141CS",
@@ -498,39 +485,6 @@
"port": 554, "port": 554,
"url": "/onvif2" "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": [ "models": [
"2MP", "2MP",
@@ -659,15 +613,6 @@
"port": 80, "port": 80,
"url": "/cgi-bin/snapshot.cgi?chn=4&u=[USERNAME]&p=[PASSWORD]" "url": "/cgi-bin/snapshot.cgi?chn=4&u=[USERNAME]&p=[PASSWORD]"
}, },
{
"models": [
"DVR"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/201"
},
{ {
"models": [ "models": [
"h264", "h264",
@@ -851,6 +796,97 @@
"protocol": "rtsp", "protocol": "rtsp",
"port": 0, "port": 0,
"url": "/h264/ch1/main/av_stream" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"Other" "Other"
+36
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"Outdoor Smart Home Camera", "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"040", "040",
@@ -47,69 +164,6 @@
"port": 554, "port": 554,
"url": "/Streaming/Channels/1" "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": [ "models": [
"DC-I200", "DC-I200",
@@ -221,16 +275,6 @@
"port": 554, "port": 554,
"url": "/h264_stream" "url": "/h264_stream"
}, },
{
"models": [
"ds-i200",
"VDP-D2201"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 555,
"url": "/Streaming/Channels/102"
},
{ {
"models": [ "models": [
"Ds-i203" "Ds-i203"
@@ -240,16 +284,6 @@
"port": 8000, "port": 8000,
"url": "/" "url": "/"
}, },
{
"models": [
"DS-I214(B)",
"DS-I405"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/102"
},
{ {
"models": [ "models": [
"DS-I220", "DS-I220",
@@ -310,6 +344,89 @@
"protocol": "rtsp", "protocol": "rtsp",
"port": 554, "port": 554,
"url": "/onvif1" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"0010/0020", "0010/0020",
@@ -647,15 +665,6 @@
"port": 0, "port": 0,
"url": "cam[CHANNEL]/h264" "url": "cam[CHANNEL]/h264"
}, },
{
"models": [
"FCS-3084"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
},
{ {
"models": [ "models": [
"FCS-4051", "FCS-4051",
@@ -770,6 +779,15 @@
"protocol": "http", "protocol": "http",
"port": 80, "port": 80,
"url": "/cgi-bin/video.jpg" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"0v600-365-kd", "0v600-365-kd",
@@ -153,24 +171,6 @@
"port": 0, "port": 0,
"url": "snapshot.jpg?account=[USERNAME]&password=[PASSWORD]" "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": [ "models": [
"CAM-1", "CAM-1",
@@ -375,6 +375,24 @@
"protocol": "http", "protocol": "http",
"port": 80, "port": 80,
"url": "/cgi-bin/snapshot.cgi?chn=0&u=[USERNAME]&p=[PASSWORD]" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"801", "801",
+128 -92
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"005FTCD", "005FTCD",
@@ -588,58 +624,6 @@
"port": 554, "port": 554,
"url": "/ch05/1" "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": [ "models": [
"887" "887"
@@ -874,37 +858,6 @@
"port": 0, "port": 0,
"url": "/Streaming/Unicast/channels/401" "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": [ "models": [
"DVR4", "DVR4",
@@ -942,15 +895,6 @@
"port": 80, "port": 80,
"url": "/?action=stream" "url": "/?action=stream"
}, },
{
"models": [
"DVR8-4500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/301"
},
{ {
"models": [ "models": [
"DVR8-4500", "DVR8-4500",
@@ -1314,6 +1258,98 @@
"protocol": "rtsp", "protocol": "rtsp",
"port": 0, "port": 0,
"url": "/Streaming/Channels/2" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"TBK-BUL8841Z" "TBK-BUL8841Z"
+2407
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -31,6 +31,54 @@
"notes": "Common RTSP sub stream for ONVIF cameras", "notes": "Common RTSP sub stream for ONVIF cameras",
"model_count": 9998 "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", "url": "/ch2",
"type": "FFMPEG", "type": "FFMPEG",
+1 -4
View File
@@ -9,13 +9,10 @@ services:
image: eduard256/strix:latest image: eduard256/strix:latest
container_name: strix container_name: strix
restart: unless-stopped restart: unless-stopped
ports: network_mode: host
- "4567:4567"
environment: environment:
- STRIX_LOG_LEVEL=info - STRIX_LOG_LEVEL=info
- STRIX_LOG_FORMAT=json - STRIX_LOG_FORMAT=json
networks:
- cameras
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4567/api/v1/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4567/api/v1/health"]
interval: 30s interval: 30s
+1 -3
View File
@@ -7,9 +7,7 @@ services:
# build: . # build: .
container_name: strix container_name: strix
restart: unless-stopped restart: unless-stopped
network_mode: host
ports:
- "4567:4567"
environment: environment:
# Logging configuration # Logging configuration
+16 -3
View File
@@ -5,6 +5,7 @@ go 1.24.0
toolchain go1.24.9 toolchain go1.24.9
require ( require (
github.com/AlexxIT/go2rtc v1.9.14
github.com/IOTechSystems/onvif v1.2.0 github.com/IOTechSystems/onvif v1.2.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-playground/validator/v10 v10.28.0 github.com/go-playground/validator/v10 v10.28.0
@@ -20,8 +21,20 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.42.0 // indirect github.com/miekg/dns v1.1.70 // indirect
golang.org/x/sys v0.36.0 // indirect github.com/pion/randutil v0.1.0 // indirect
golang.org/x/text v0.29.0 // indirect github.com/pion/rtp v1.10.0 // indirect
github.com/pion/sdp/v3 v3.0.17 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
) )
+37 -9
View File
@@ -1,9 +1,12 @@
github.com/AlexxIT/go2rtc v1.9.14 h1:HjaZ2pR64nTkoTZcKM8Zjybg7swyCZbA8Biru1mbqcY=
github.com/AlexxIT/go2rtc v1.9.14/go.mod h1:fcN11KXBbIcExYRqMVDlW7amLZ4/z+hpr5+318fBA9U=
github.com/IOTechSystems/onvif v1.2.0 h1:vplyPdhFhMRtIdkEbQIkTlrKjXpeDj+WUTt5UW61ZcI= github.com/IOTechSystems/onvif v1.2.0 h1:vplyPdhFhMRtIdkEbQIkTlrKjXpeDj+WUTt5UW61ZcI=
github.com/IOTechSystems/onvif v1.2.0/go.mod h1:/dTr5BtFaGojYGJ2rEBIVWh3seGIcSuCJhcK9zwTsk0= github.com/IOTechSystems/onvif v1.2.0/go.mod h1:/dTr5BtFaGojYGJ2rEBIVWh3seGIcSuCJhcK9zwTsk0=
github.com/beevik/etree v1.4.1 h1:PmQJDDYahBGNKDcpdX8uPy1xRCwoCGVUiW669MEirVI= github.com/beevik/etree v1.4.1 h1:PmQJDDYahBGNKDcpdX8uPy1xRCwoCGVUiW669MEirVI=
github.com/beevik/etree v1.4.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= github.com/beevik/etree v1.4.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elgs/gostrgen v0.0.0-20220325073726-0c3e00d082f6 h1:x9TA+vnGEyqmWY+eA9HfgxNRkOQqwiEpFE9IPXSGuEA= github.com/elgs/gostrgen v0.0.0-20220325073726-0c3e00d082f6 h1:x9TA+vnGEyqmWY+eA9HfgxNRkOQqwiEpFE9IPXSGuEA=
@@ -22,36 +25,58 @@ 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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 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/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w=
github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -60,14 +85,17 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1 -1
View File
@@ -43,7 +43,7 @@ func NewHealthHandler(version string, logger interface{ Info(string, ...any) })
// ServeHTTP handles health check requests // ServeHTTP handles health check requests
func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
+82
View File
@@ -0,0 +1,82 @@
package handlers
import (
"encoding/json"
"net"
"net/http"
"github.com/eduard256/Strix/internal/camera/discovery"
)
// ProbeHandler handles device probe requests.
// GET /api/v1/probe?ip=192.168.1.50
type ProbeHandler struct {
probeService *discovery.ProbeService
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
}
}
// NewProbeHandler creates a new probe handler.
func NewProbeHandler(
probeService *discovery.ProbeService,
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
},
) *ProbeHandler {
return &ProbeHandler{
probeService: probeService,
logger: logger,
}
}
// ServeHTTP handles probe requests.
func (h *ProbeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ip := r.URL.Query().Get("ip")
if ip == "" {
h.sendError(w, "Missing required parameter: ip", http.StatusBadRequest)
return
}
// Validate IP format
if net.ParseIP(ip) == nil {
h.sendError(w, "Invalid IP address: "+ip, http.StatusBadRequest)
return
}
h.logger.Info("probe requested", "ip", ip, "remote_addr", r.RemoteAddr)
// Run probe
result := h.probeService.Probe(r.Context(), ip)
// Send response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(result); err != nil {
h.logger.Error("failed to encode probe response", err)
}
}
// sendError sends a JSON error response.
func (h *ProbeHandler) sendError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := map[string]interface{}{
"error": true,
"message": message,
"code": statusCode,
}
_ = json.NewEncoder(w).Encode(response)
}
+26 -2
View File
@@ -20,6 +20,7 @@ type Server struct {
loader *database.Loader loader *database.Loader
searchEngine *database.SearchEngine searchEngine *database.SearchEngine
scanner *discovery.Scanner scanner *discovery.Scanner
probeService *discovery.ProbeService
sseServer *sse.Server sseServer *sse.Server
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) } logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }
} }
@@ -75,6 +76,23 @@ func NewServer(
// Initialize SSE server // Initialize SSE server
sseServer := sse.NewServer(logger) sseServer := sse.NewServer(logger)
// Initialize OUI database for vendor identification
ouiDB := discovery.NewOUIDatabase()
if err := ouiDB.LoadFromFile(cfg.Database.OUIPath); err != nil {
logger.Error("failed to load OUI database, vendor lookup will be unavailable", err)
} else {
logger.Info("OUI database loaded", "entries", ouiDB.Size())
}
// Initialize ProbeService with all probers
probers := []discovery.Prober{
&discovery.DNSProber{},
discovery.NewARPProber(ouiDB),
&discovery.MDNSProber{},
&discovery.HTTPProber{},
}
probeService := discovery.NewProbeService(probers, logger)
// Create server // Create server
server := &Server{ server := &Server{
router: chi.NewRouter(), router: chi.NewRouter(),
@@ -82,6 +100,7 @@ func NewServer(
loader: loader, loader: loader,
searchEngine: searchEngine, searchEngine: searchEngine,
scanner: scanner, scanner: scanner,
probeService: probeService,
sseServer: sseServer, sseServer: sseServer,
logger: logger, logger: logger,
} }
@@ -119,14 +138,19 @@ func (s *Server) setupRoutes() {
}) })
// API routes (mounted at /api/v1 in main.go) // API routes (mounted at /api/v1 in main.go)
// Health check // Health check (GET + HEAD for Docker/CasaOS healthcheck compatibility)
s.router.Get("/health", handlers.NewHealthHandler("1.0.0", s.logger).ServeHTTP) healthHandler := handlers.NewHealthHandler(s.config.Version, s.logger).ServeHTTP
s.router.Get("/health", healthHandler)
s.router.Head("/health", healthHandler)
// Camera search // Camera search
s.router.Post("/cameras/search", handlers.NewSearchHandler(s.searchEngine, s.logger).ServeHTTP) s.router.Post("/cameras/search", handlers.NewSearchHandler(s.searchEngine, s.logger).ServeHTTP)
// Stream discovery (SSE) // Stream discovery (SSE)
s.router.Post("/streams/discover", handlers.NewDiscoverHandler(s.scanner, s.sseServer, s.logger).ServeHTTP) s.router.Post("/streams/discover", handlers.NewDiscoverHandler(s.scanner, s.sseServer, s.logger).ServeHTTP)
// Device probe (ping + DNS + ARP/OUI + mDNS)
s.router.Get("/probe", handlers.NewProbeHandler(s.probeService, s.logger).ServeHTTP)
} }
// ServeHTTP implements http.Handler // ServeHTTP implements http.Handler
+76
View File
@@ -0,0 +1,76 @@
package discovery
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
)
// OUIDatabase provides MAC address prefix to vendor name lookup.
// Data is loaded from a JSON file containing camera/surveillance vendor OUI prefixes.
type OUIDatabase struct {
data map[string]string // "C0:56:E3" -> "Hikvision"
mu sync.RWMutex
}
// NewOUIDatabase creates an empty OUI database.
func NewOUIDatabase() *OUIDatabase {
return &OUIDatabase{
data: make(map[string]string),
}
}
// LoadFromFile loads OUI data from a JSON file.
// Expected format: {"C0:56:E3": "Hikvision", "54:EF:44": "Lumi/Aqara", ...}
func (db *OUIDatabase) LoadFromFile(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open OUI database: %w", err)
}
defer file.Close()
var data map[string]string
if err := json.NewDecoder(file).Decode(&data); err != nil {
return fmt.Errorf("failed to decode OUI database: %w", err)
}
// Normalize all keys to uppercase
normalized := make(map[string]string, len(data))
for k, v := range data {
normalized[strings.ToUpper(k)] = v
}
db.mu.Lock()
db.data = normalized
db.mu.Unlock()
return nil
}
// LookupVendor returns the vendor name for a given MAC address.
// MAC can be in any format: "C0:56:E3:AA:BB:CC", "c0:56:e3:aa:bb:cc", "C0-56-E3-AA-BB-CC".
// Returns empty string if not found.
func (db *OUIDatabase) LookupVendor(mac string) string {
if len(mac) < 8 {
return ""
}
// Normalize: uppercase and replace dashes with colons
prefix := strings.ToUpper(mac[:8])
prefix = strings.ReplaceAll(prefix, "-", ":")
db.mu.RLock()
vendor := db.data[prefix]
db.mu.RUnlock()
return vendor
}
// Size returns the number of entries in the database.
func (db *OUIDatabase) Size() int {
db.mu.RLock()
defer db.mu.RUnlock()
return len(db.data)
}
+184
View File
@@ -0,0 +1,184 @@
package discovery
import (
"context"
"sync"
"time"
"github.com/eduard256/Strix/internal/models"
)
const (
// ProbeTimeout is the overall timeout for all probes combined.
ProbeTimeout = 3 * time.Second
// ProbeTypeUnreachable indicates the device did not respond to ping.
ProbeTypeUnreachable = "unreachable"
// ProbeTypeStandard indicates a normal IP camera (RTSP/HTTP/ONVIF).
ProbeTypeStandard = "standard"
// ProbeTypeHomeKit indicates an Apple HomeKit camera that needs PIN pairing.
ProbeTypeHomeKit = "homekit"
)
// Prober is an interface for network probe implementations.
// Each prober discovers specific information about a device at a given IP.
// New probers can be added by implementing this interface and registering
// them with ProbeService.
type Prober interface {
// Name returns a unique identifier for this prober (e.g., "dns", "arp", "mdns").
Name() string
// Probe runs the probe against the given IP address.
// Must respect context cancellation/timeout.
// Returns nil result if nothing was found (not an error).
Probe(ctx context.Context, ip string) (any, error)
}
// ProbeService orchestrates multiple probers to gather information about a device.
// It first pings the device, then runs all registered probers in parallel.
type ProbeService struct {
pinger *PingProber
probers []Prober
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
}
}
// NewProbeService creates a new ProbeService with the given probers.
// The ping prober is always included and runs first.
func NewProbeService(
probers []Prober,
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
},
) *ProbeService {
return &ProbeService{
pinger: &PingProber{},
probers: probers,
logger: logger,
}
}
// Probe runs ping + all registered probers against the given IP.
// Overall timeout is 3 seconds. Results are collected from whatever
// finishes in time; slow probers are omitted (nil in response).
func (s *ProbeService) Probe(ctx context.Context, ip string) *models.ProbeResponse {
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
defer cancel()
response := &models.ProbeResponse{
IP: ip,
Type: ProbeTypeStandard,
}
// Step 1: Ping
s.logger.Debug("probing device", "ip", ip)
pingResult, err := s.pinger.Ping(ctx, ip)
if err != nil || !pingResult.Reachable {
errMsg := "device unreachable"
if err != nil {
errMsg = err.Error()
}
s.logger.Debug("ping failed", "ip", ip, "error", errMsg)
response.Reachable = false
response.Type = ProbeTypeUnreachable
response.Error = errMsg
return response
}
response.Reachable = true
response.LatencyMs = pingResult.LatencyMs
s.logger.Debug("ping OK", "ip", ip, "latency_ms", pingResult.LatencyMs)
// Step 2: Run all probers in parallel
type probeResult struct {
name string
data any
err error
}
results := make(chan probeResult, len(s.probers))
var wg sync.WaitGroup
for _, p := range s.probers {
wg.Add(1)
go func(prober Prober) {
defer wg.Done()
data, err := prober.Probe(ctx, ip)
results <- probeResult{
name: prober.Name(),
data: data,
err: err,
}
}(p)
}
// Close results channel when all probers finish
go func() {
wg.Wait()
close(results)
}()
// Collect results
for r := range results {
if r.err != nil {
s.logger.Debug("prober failed", "prober", r.name, "error", r.err.Error())
continue
}
if r.data == nil {
continue
}
switch r.name {
case "dns":
if v, ok := r.data.(*models.DNSProbeResult); ok {
response.Probes.DNS = v
}
case "arp":
if v, ok := r.data.(*models.ARPProbeResult); ok {
response.Probes.ARP = v
}
case "mdns":
if v, ok := r.data.(*models.MDNSProbeResult); ok {
response.Probes.MDNS = v
}
case "http":
if v, ok := r.data.(*models.HTTPProbeResult); ok {
response.Probes.HTTP = v
}
}
}
// Step 3: Determine type based on probe results
response.Type = s.determineType(response)
s.logger.Info("probe completed",
"ip", ip,
"reachable", response.Reachable,
"type", response.Type,
"latency_ms", response.LatencyMs,
)
return response
}
// determineType decides the device type based on collected probe results.
func (s *ProbeService) determineType(response *models.ProbeResponse) string {
if !response.Reachable {
return ProbeTypeUnreachable
}
// HomeKit camera that is not yet paired
if response.Probes.MDNS != nil && !response.Probes.MDNS.Paired {
category := response.Probes.MDNS.Category
if category == "camera" || category == "doorbell" {
return ProbeTypeHomeKit
}
}
return ProbeTypeStandard
}
+80
View File
@@ -0,0 +1,80 @@
package discovery
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/eduard256/Strix/internal/models"
)
// ARPProber looks up the MAC address from the system ARP table
// and resolves it to a vendor name using the OUI database.
type ARPProber struct {
ouiDB *OUIDatabase
}
// NewARPProber creates a new ARP prober with the given OUI database.
func NewARPProber(ouiDB *OUIDatabase) *ARPProber {
return &ARPProber{ouiDB: ouiDB}
}
func (p *ARPProber) Name() string { return "arp" }
// Probe looks up the MAC address for the given IP in the ARP table.
// Returns nil if the IP is not in the ARP table (e.g., different subnet, VPN).
// This only works on Linux (reads /proc/net/arp).
func (p *ARPProber) Probe(ctx context.Context, ip string) (any, error) {
mac, err := p.lookupARP(ip)
if err != nil || mac == "" {
return nil, nil // Not in ARP table is not an error
}
vendor := ""
if p.ouiDB != nil {
vendor = p.ouiDB.LookupVendor(mac)
}
return &models.ARPProbeResult{
MAC: mac,
Vendor: vendor,
}, nil
}
// lookupARP reads /proc/net/arp to find the MAC address for the given IP.
//
// Format of /proc/net/arp:
//
// IP address HW type Flags HW address Mask Device
// 192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0
func (p *ARPProber) lookupARP(ip string) (string, error) {
file, err := os.Open("/proc/net/arp")
if err != nil {
return "", fmt.Errorf("failed to open ARP table: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Scan() // Skip header line
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 4 {
continue
}
// fields[0] = IP address, fields[3] = HW address
if fields[0] == ip {
mac := fields[3]
// "00:00:00:00:00:00" means incomplete ARP entry
if mac == "00:00:00:00:00:00" {
return "", nil
}
return strings.ToUpper(mac), nil
}
}
return "", nil
}
+36
View File
@@ -0,0 +1,36 @@
package discovery
import (
"context"
"net"
"strings"
"github.com/eduard256/Strix/internal/models"
)
// DNSProber performs reverse DNS lookup to find the hostname of a device.
type DNSProber struct{}
func (p *DNSProber) Name() string { return "dns" }
// Probe performs a reverse DNS lookup on the given IP.
// Returns nil if no hostname is found (not an error).
func (p *DNSProber) Probe(ctx context.Context, ip string) (any, error) {
resolver := net.DefaultResolver
names, err := resolver.LookupAddr(ctx, ip)
if err != nil || len(names) == 0 {
return nil, nil // No hostname found is not an error
}
// LookupAddr returns FQDNs with trailing dot, remove it
hostname := strings.TrimSuffix(names[0], ".")
if hostname == "" {
return nil, nil
}
return &models.DNSProbeResult{
Hostname: hostname,
}, nil
}
+87
View File
@@ -0,0 +1,87 @@
package discovery
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"github.com/eduard256/Strix/internal/models"
)
// HTTPProber identifies the device by checking HTTP server headers.
// It sends HEAD and GET requests in parallel to port 80 (some devices
// like XMEye/JAWS don't respond to HEAD), and returns whichever
// responds first.
type HTTPProber struct{}
func (p *HTTPProber) Name() string { return "http" }
// Probe sends parallel HEAD+GET to port 80 and extracts Server header.
// Returns nil if no HTTP server is found.
func (p *HTTPProber) Probe(ctx context.Context, ip string) (any, error) {
ports := []int{80, 8080}
client := &http.Client{
// Don't follow redirects -- we want the original response headers
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
type result struct {
resp *http.Response
port int
err error
}
for _, port := range ports {
url := fmt.Sprintf("http://%s:%d/", ip, port)
ch := make(chan result, 2)
// HEAD and GET in parallel -- take whichever responds first
for _, method := range []string{"HEAD", "GET"} {
go func(method string) {
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
ch <- result{err: err}
return
}
req.Header.Set("User-Agent", "Strix/1.0")
resp, err := client.Do(req)
ch <- result{resp: resp, port: port, err: err}
}(method)
}
// Wait for first success
for i := 0; i < 2; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-ch:
if r.err != nil {
continue
}
if r.resp.Body != nil {
r.resp.Body.Close()
}
server := r.resp.Header.Get("Server")
if server == "" && r.resp.StatusCode == 0 {
continue
}
return &models.HTTPProbeResult{
Port: r.port,
StatusCode: r.resp.StatusCode,
Server: server,
}, nil
}
}
}
return nil, nil
}
+95
View File
@@ -0,0 +1,95 @@
package discovery
import (
"context"
"time"
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/mdns"
"github.com/eduard256/Strix/internal/models"
)
const (
// mdnsTimeout is the maximum time to wait for mDNS response.
// HomeKit devices respond in 2-10ms. If no response in 100ms,
// the device is definitely not a HomeKit camera.
// The underlying mdns.Query has a 1s internal timeout, but we
// cut it short with this context-based wrapper.
mdnsTimeout = 100 * time.Millisecond
)
// MDNSProber performs mDNS unicast query to detect HomeKit devices.
// It sends a DNS query to ip:5353 for the _hap._tcp.local. service
// and parses TXT records to extract device information.
// Uses a 100ms timeout wrapper around go2rtc's mdns.Query to avoid
// waiting the full 1s on non-HomeKit devices.
type MDNSProber struct{}
func (p *MDNSProber) Name() string { return "mdns" }
// Probe queries the device for HomeKit (HAP) mDNS service.
// Returns nil if the device does not advertise HomeKit or is not a camera/doorbell.
func (p *MDNSProber) Probe(ctx context.Context, ip string) (any, error) {
// Run mdns.Query in a goroutine with 100ms timeout.
// mdns.Query has an internal 1s timeout and doesn't accept context,
// so we wrap it. The background goroutine will clean up on its own
// after the internal timeout expires (~1s, negligible resource cost).
type queryResult struct {
entry *mdns.ServiceEntry
err error
}
ch := make(chan queryResult, 1)
go func() {
entry, err := mdns.Query(ip, mdns.ServiceHAP)
ch <- queryResult{entry, err}
}()
// Wait for result or timeout
timer := time.NewTimer(mdnsTimeout)
defer timer.Stop()
var entry *mdns.ServiceEntry
select {
case r := <-ch:
if r.err != nil || r.entry == nil {
return nil, nil
}
entry = r.entry
case <-timer.C:
return nil, nil // No response within 100ms -- not a HomeKit device
case <-ctx.Done():
return nil, nil
}
// Check if it's complete (has IP, port, and TXT records)
if !entry.Complete() {
return nil, nil
}
// Check if it's a camera or doorbell
category := entry.Info[hap.TXTCategory]
if category != hap.CategoryCamera && category != hap.CategoryDoorbell {
return nil, nil // Not a camera/doorbell, ignore
}
// Map category ID to human-readable name
categoryName := "camera"
if category == hap.CategoryDoorbell {
categoryName = "doorbell"
}
// Determine paired status: sf=0 means paired, sf=1 means not paired
paired := entry.Info[hap.TXTStatusFlags] == hap.StatusPaired
return &models.MDNSProbeResult{
Name: entry.Name,
DeviceID: entry.Info[hap.TXTDeviceID],
Model: entry.Info[hap.TXTModel],
Category: categoryName,
Paired: paired,
Port: int(entry.Port),
Feature: entry.Info[hap.TXTFeatureFlags],
}, nil
}
+127
View File
@@ -0,0 +1,127 @@
package discovery
import (
"context"
"fmt"
"net"
"time"
)
// PingResult contains the result of a ping probe.
type PingResult struct {
Reachable bool
LatencyMs float64
}
// PingProber checks if a device is reachable on the network.
// It tries ICMP ping first (requires root/CAP_NET_RAW), then falls back
// to TCP connect on common camera ports (80, 554, 443, 8080).
type PingProber struct{}
// Ping checks if the device at the given IP is reachable.
func (p *PingProber) Ping(ctx context.Context, ip string) (*PingResult, error) {
// Try ICMP first (works if running as root or with CAP_NET_RAW)
result, err := p.tryICMP(ctx, ip)
if err == nil {
return result, nil
}
// Fallback: TCP connect on common camera ports
result, err = p.tryTCP(ctx, ip)
if err == nil {
return result, nil
}
return &PingResult{Reachable: false}, fmt.Errorf("device unreachable: %s", ip)
}
// tryICMP attempts an ICMP ping using raw socket.
func (p *PingProber) tryICMP(ctx context.Context, ip string) (*PingResult, error) {
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(2 * time.Second)
}
timeout := time.Until(deadline)
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
// Cap ICMP timeout to 2 seconds to leave time for other probes
if timeout > 2*time.Second {
timeout = 2 * time.Second
}
start := time.Now()
conn, err := net.DialTimeout("ip4:icmp", ip, timeout)
if err != nil {
return nil, err
}
conn.Close()
return &PingResult{
Reachable: true,
LatencyMs: float64(time.Since(start).Microseconds()) / 1000.0,
}, nil
}
// tryTCP attempts TCP connect on common camera ports as a ping fallback.
// This works without root privileges and is reliable for cameras since
// they almost always have at least one of these ports open.
func (p *PingProber) tryTCP(ctx context.Context, ip string) (*PingResult, error) {
commonPorts := []int{80, 554, 443, 8080, 8443, 34567, 5353}
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(2 * time.Second)
}
timeout := time.Until(deadline)
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
// Cap per-port timeout
perPortTimeout := timeout / time.Duration(len(commonPorts))
if perPortTimeout > 500*time.Millisecond {
perPortTimeout = 500 * time.Millisecond
}
type tcpResult struct {
latency time.Duration
err error
}
results := make(chan tcpResult, len(commonPorts))
for _, port := range commonPorts {
go func(port int) {
addr := fmt.Sprintf("%s:%d", ip, port)
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, perPortTimeout)
if err != nil {
results <- tcpResult{err: err}
return
}
conn.Close()
results <- tcpResult{latency: time.Since(start)}
}(port)
}
// Wait for first success or all failures
var lastErr error
for range commonPorts {
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-results:
if r.err == nil {
return &PingResult{
Reachable: true,
LatencyMs: float64(r.latency.Microseconds()) / 1000.0,
}, nil
}
lastErr = r.err
}
}
return nil, fmt.Errorf("all TCP ports closed: %w", lastErr)
}
+15 -12
View File
@@ -302,11 +302,13 @@ func (s *Scanner) collectStreams(ctx context.Context, req models.StreamDiscovery
"model", req.Model, "model", req.Model,
"limit", req.ModelLimit) "limit", req.ModelLimit)
// Search for similar models // Search for cameras using intelligent brand+model search
cameras, err := s.searchEngine.SearchByModel(req.Model, 0.8, req.ModelLimit) searchResp, err := s.searchEngine.Search(req.Model, req.ModelLimit)
if err != nil { if err != nil {
s.logger.Error("model search failed", err) s.logger.Error("model search failed", err)
} else { } else {
cameras := searchResp.Cameras
// Collect entries from all matching cameras // Collect entries from all matching cameras
var entries []models.CameraEntry var entries []models.CameraEntry
for _, camera := range cameras { for _, camera := range cameras {
@@ -409,26 +411,27 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
defer cancelProgress() defer cancelProgress()
go func() { go func() {
ticker := time.NewTicker(3 * time.Second) // Use longer interval for Ingress mode to reduce traffic (padding is ~64KB per event)
defer ticker.Stop() // Normal mode: 1 second, Ingress mode: 3 seconds
progressInterval := 1 * time.Second
if streamWriter.IsIngress() {
progressInterval = 3 * time.Second
}
lastTested := int32(0) ticker := time.NewTicker(progressInterval)
defer ticker.Stop()
for { for {
select { select {
case <-progressCtx.Done(): case <-progressCtx.Done():
return return
case <-ticker.C: case <-ticker.C:
currentTested := atomic.LoadInt32(&tested) // Send progress to prevent WriteTimeout and show scanning activity
// Only send if there's been progress
if currentTested != lastTested {
_ = streamWriter.SendJSON("progress", models.ProgressMessage{ _ = streamWriter.SendJSON("progress", models.ProgressMessage{
Tested: int(currentTested), Tested: int(atomic.LoadInt32(&tested)),
Found: int(atomic.LoadInt32(&found)), Found: int(atomic.LoadInt32(&found)),
Remaining: len(streams) - int(currentTested), Remaining: len(streams) - int(atomic.LoadInt32(&tested)),
}) })
lastTested = currentTested
}
} }
} }
}() }()
+6 -2
View File
@@ -176,8 +176,12 @@ 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+1]": strconv.Itoa(ctx.Channel + 1), // For Hikvision-style channels (101, 201, 301...)
"{CHANNEL}": strconv.Itoa(ctx.Channel), "[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),
"[width]": strconv.Itoa(ctx.Width), "[width]": strconv.Itoa(ctx.Width),
"[HEIGHT]": strconv.Itoa(ctx.Height), "[HEIGHT]": strconv.Itoa(ctx.Height),
+48 -2
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -14,6 +15,7 @@ import (
// Config holds application configuration // Config holds application configuration
type Config struct { type Config struct {
Version string // Application version, set by caller after Load()
Server ServerConfig Server ServerConfig
Database DatabaseConfig Database DatabaseConfig
Scanner ScannerConfig Scanner ScannerConfig
@@ -33,6 +35,7 @@ type DatabaseConfig struct {
BrandsPath string BrandsPath string
PatternsPath string PatternsPath string
ParametersPath string ParametersPath string
OUIPath string
CacheEnabled bool CacheEnabled bool
CacheTTL time.Duration CacheTTL time.Duration
} }
@@ -73,13 +76,14 @@ func Load() *Config {
Server: ServerConfig{ Server: ServerConfig{
Listen: ":4567", // Default listen address Listen: ":4567", // Default listen address
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, // Increased for SSE long-polling
}, },
Database: DatabaseConfig{ Database: DatabaseConfig{
DataPath: dataPath, DataPath: dataPath,
BrandsPath: filepath.Join(dataPath, "brands"), BrandsPath: filepath.Join(dataPath, "brands"),
PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"), PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"),
ParametersPath: filepath.Join(dataPath, "query_parameters.json"), ParametersPath: filepath.Join(dataPath, "query_parameters.json"),
OUIPath: filepath.Join(dataPath, "camera_oui.json"),
CacheEnabled: true, CacheEnabled: true,
CacheTTL: 5 * time.Minute, CacheTTL: 5 * time.Minute,
}, },
@@ -102,8 +106,14 @@ func Load() *Config {
}, },
} }
// Load from strix.yaml if exists // Load from Home Assistant options.json if running as HA add-on
// Priority: defaults < HA options < strix.yaml < ENV
configSource := "default" configSource := "default"
if err := loadHAOptions(cfg); err == nil {
configSource = "/data/options.json (Home Assistant)"
}
// Load from strix.yaml if exists (overrides HA options)
if err := loadYAML(cfg); err == nil { if err := loadYAML(cfg); err == nil {
configSource = "strix.yaml" configSource = "strix.yaml"
} }
@@ -148,6 +158,42 @@ func loadYAML(cfg *Config) error {
return nil return nil
} }
// haOptions represents the structure of Home Assistant /data/options.json.
// When Strix runs as a Home Assistant add-on, HA creates this file from the
// add-on configuration UI. Fields are optional -- zero values are ignored.
type haOptions struct {
LogLevel string `json:"log_level"`
Port int `json:"port"`
}
// loadHAOptions loads configuration from Home Assistant's /data/options.json.
// This file only exists when running inside the HA add-on environment.
// Returns an error if the file doesn't exist or can't be parsed (callers
// should treat errors as "not running in HA" and silently continue).
func loadHAOptions(cfg *Config) error {
data, err := os.ReadFile("/data/options.json")
if err != nil {
return err
}
var opts haOptions
if err := json.Unmarshal(data, &opts); err != nil {
return fmt.Errorf("failed to parse /data/options.json: %w", err)
}
if opts.LogLevel != "" {
cfg.Logger.Level = opts.LogLevel
}
if opts.Port > 0 {
cfg.Server.Listen = fmt.Sprintf(":%d", opts.Port)
}
// Home Assistant add-on always uses JSON logging for the HA log viewer
cfg.Logger.Format = "json"
return nil
}
// validateListen validates the listen address format and port range // validateListen validates the listen address format and port range
func validateListen(listen string) error { func validateListen(listen string) error {
if listen == "" { if listen == "" {
+53
View File
@@ -0,0 +1,53 @@
package models
// ProbeResponse represents the result of probing an IP address.
// The Type field determines which UI flow the frontend should use:
// - "unreachable" -- device did not respond to ping
// - "standard" -- normal IP camera (RTSP/HTTP/ONVIF)
// - "homekit" -- Apple HomeKit camera (needs PIN pairing)
type ProbeResponse struct {
IP string `json:"ip"`
Reachable bool `json:"reachable"`
LatencyMs float64 `json:"latency_ms,omitempty"`
Type string `json:"type"`
Error string `json:"error,omitempty"`
Probes ProbeResults `json:"probes"`
}
// ProbeResults contains results from all parallel probers.
// Nil fields mean the prober did not find anything or timed out.
type ProbeResults struct {
DNS *DNSProbeResult `json:"dns"`
ARP *ARPProbeResult `json:"arp"`
MDNS *MDNSProbeResult `json:"mdns"`
HTTP *HTTPProbeResult `json:"http"`
}
// HTTPProbeResult contains HTTP server identification from port 80.
type HTTPProbeResult struct {
Port int `json:"port"`
StatusCode int `json:"status_code"`
Server string `json:"server"`
}
// DNSProbeResult contains reverse DNS lookup result.
type DNSProbeResult struct {
Hostname string `json:"hostname"`
}
// ARPProbeResult contains ARP table lookup + OUI vendor identification.
type ARPProbeResult struct {
MAC string `json:"mac"`
Vendor string `json:"vendor"`
}
// MDNSProbeResult contains mDNS service discovery result (HomeKit).
type MDNSProbeResult struct {
Name string `json:"name"`
DeviceID string `json:"device_id"`
Model string `json:"model"`
Category string `json:"category"` // "camera", "doorbell"
Paired bool `json:"paired"`
Port int `json:"port"`
Feature string `json:"feature"`
}
+63 -1
View File
@@ -5,9 +5,20 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "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 // Event represents a Server-Sent Event
type Event struct { type Event struct {
ID string ID string
@@ -255,6 +266,7 @@ func generateClientID() string {
type StreamWriter struct { type StreamWriter struct {
client *Client client *Client
server *Server server *Server
isIngress bool // True when running through Home Assistant Ingress proxy
} }
// NewStreamWriter creates a new stream writer for a client // 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 // Send initial flush to establish connection
flusher.Flush() flusher.Flush()
// Detect Home Assistant Ingress mode by checking for X-Ingress-Path header
isIngress := r.Header.Get(IngressHeader) != ""
// Create client // Create client
ctx, cancel := context.WithCancel(r.Context()) ctx, cancel := context.WithCancel(r.Context())
client := &Client{ client := &Client{
@@ -289,6 +304,7 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
return &StreamWriter{ return &StreamWriter{
client: client, client: client,
server: s, server: s,
isIngress: isIngress,
}, nil }, nil
} }
@@ -304,7 +320,48 @@ func (sw *StreamWriter) SendEvent(eventType string, data interface{}) error {
return fmt.Errorf("response does not support flushing") 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 // 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) 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 // SendMessage sends a simple message
func (sw *StreamWriter) SendMessage(message string) error { func (sw *StreamWriter) SendMessage(message string) error {
return sw.SendEvent("message", map[string]string{"message": message}) return sw.SendEvent("message", map[string]string{"message": message})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "webui", "name": "webui",
"version": "1.0.4", "version": "0.0.0",
"type": "module", "type": "module",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
+589 -101
View File
@@ -79,6 +79,37 @@ body {
overflow-x: hidden; overflow-x: hidden;
} }
/* ===== MOCK MODE BADGE ===== */
.mock-badge {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(245, 158, 11, 0.15);
border: 1px solid var(--warning);
border-radius: 6px;
color: var(--warning);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.05em;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2);
backdrop-filter: blur(10px);
animation: fadeIn var(--transition-base);
}
.mock-badge svg {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ===== LAYOUT ===== */ /* ===== LAYOUT ===== */
#app { #app {
min-height: 100vh; min-height: 100vh;
@@ -555,148 +586,318 @@ body {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
/* ===== CAROUSEL ===== */ /* ===== STREAMS LIST ===== */
.carousel-wrapper { .streams-list {
position: relative; display: flex;
flex-direction: column;
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; display: flex;
align-items: center; align-items: center;
gap: var(--space-4); 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); margin-bottom: var(--space-4);
} }
.carousel { .stream-subgroup-header {
flex: 1; 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;
}
.streams-list::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
.streams-list::-webkit-scrollbar-thumb {
background: var(--purple-primary);
border-radius: 4px;
}
.streams-list::-webkit-scrollbar-thumb:hover {
background: var(--purple-light);
}
/* Stream item */
.stream-item {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
transition: all var(--transition-base);
overflow: hidden; overflow: hidden;
} }
.carousel-track { .stream-item:hover {
display: flex;
transition: transform var(--transition-slow);
}
.stream-card {
flex: 0 0 100%;
width: 100%;
padding: var(--space-6);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
transition: all var(--transition-base);
}
.stream-card:hover {
border-color: var(--purple-primary); border-color: var(--purple-primary);
box-shadow: 0 8px 24px var(--purple-glow); box-shadow: 0 4px 12px var(--purple-glow);
} }
.stream-type { .stream-item.expanded {
border-color: var(--purple-primary);
}
/* Stream item header */
.stream-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
cursor: pointer;
}
.stream-item-main {
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1;
min-width: 0;
}
.stream-info-left {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.stream-type-badge {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
color: var(--purple-primary); color: var(--purple-primary);
margin-bottom: var(--space-4);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
white-space: nowrap;
} }
.stream-type svg { .stream-type-badge svg {
width: 20px; width: 20px;
height: 20px; height: 20px;
flex-shrink: 0;
} }
.stream-url { .stream-url-preview {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stream-toggle {
background: none;
border: none;
padding: var(--space-2);
cursor: pointer;
color: var(--text-secondary);
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.stream-toggle:hover {
color: var(--purple-primary);
}
.stream-toggle .chevron {
transition: transform var(--transition-fast);
}
.stream-item.expanded .stream-toggle .chevron {
transform: rotate(180deg);
}
.btn-use-stream {
flex-shrink: 0;
white-space: nowrap;
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
}
/* Stream item details */
.stream-item-details {
max-height: 0;
overflow: hidden;
transition: max-height var(--transition-base);
padding: 0 var(--space-4);
}
.stream-item-details.visible {
max-height: 500px;
padding: 0 var(--space-4) var(--space-4) var(--space-4);
}
.stream-url-full {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-primary); color: var(--text-primary);
word-break: break-all; word-break: break-all;
margin-bottom: var(--space-4); margin-bottom: var(--space-3);
padding: var(--space-3); padding: var(--space-3);
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-radius: 6px; border-radius: 6px;
border: 1px solid var(--border-color);
} }
.stream-meta { .stream-meta-item {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
.stream-actions { .stream-meta-item:last-child {
margin-top: var(--space-6); margin-bottom: 0;
} }
.carousel-arrow { .meta-label {
flex-shrink: 0; font-weight: 600;
width: 48px; color: var(--text-primary);
height: 48px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
color: var(--text-secondary);
}
.carousel-arrow:hover:not(:disabled) {
background: var(--purple-primary);
border-color: var(--purple-primary);
color: white;
box-shadow: 0 4px 12px var(--purple-glow);
}
.carousel-arrow:disabled {
opacity: 0.3;
cursor: not-allowed;
}
@media (max-width: 767px) {
.carousel-wrapper {
flex-direction: column;
gap: var(--space-3);
}
.carousel-arrow {
display: none;
}
}
.carousel-info {
text-align: center;
}
.carousel-counter {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
.carousel-dots {
display: flex;
justify-content: center;
gap: var(--space-2);
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(139, 92, 246, 0.3);
border: none;
cursor: pointer;
transition: all var(--transition-base);
padding: 0;
}
.carousel-dot.active {
width: 24px;
border-radius: 4px;
background: var(--purple-primary);
box-shadow: 0 0 8px var(--purple-glow);
} }
/* ===== SELECTED STREAM INFO ===== */ /* ===== SELECTED STREAM INFO ===== */
@@ -718,6 +919,9 @@ body {
} }
.stream-label { .stream-label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: 600; font-weight: 600;
color: var(--text-tertiary); color: var(--text-tertiary);
@@ -889,6 +1093,70 @@ body {
display: none; display: none;
} }
/* ===== MODAL ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
opacity: 0;
transition: opacity var(--transition-base);
padding: var(--space-4);
}
.modal-overlay.show {
opacity: 1;
}
.modal-overlay.hidden {
display: none;
}
.modal {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: var(--space-8);
max-width: 400px;
width: 100%;
box-shadow: var(--shadow-lg);
transform: scale(0.95);
transition: transform var(--transition-base);
text-align: center;
}
.modal-overlay.show .modal {
transform: scale(1);
}
.modal-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--error);
margin-bottom: var(--space-4);
}
.modal-message {
font-size: var(--text-base);
color: var(--text-secondary);
margin-bottom: var(--space-8);
line-height: 1.6;
}
.modal-actions {
display: flex;
gap: var(--space-3);
}
.modal-actions .btn {
flex: 1;
padding: var(--space-4);
}
/* ===== ANIMATIONS ===== */ /* ===== ANIMATIONS ===== */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
@@ -976,6 +1244,104 @@ body {
transform: translateY(0); transform: translateY(0);
} }
/* Button with tooltip wrapper */
.button-with-tooltip {
position: relative;
width: 100%;
}
.button-with-tooltip .btn-generate {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
/* Button with tooltip in secondary-actions */
.secondary-actions .button-with-tooltip {
flex: 1.2;
width: auto;
}
.secondary-actions .button-with-tooltip .btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.secondary-actions .button-with-tooltip:last-child {
flex: 0.8;
}
/* Info icon inside button */
.info-icon-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
cursor: help;
color: rgba(255, 255, 255, 0.7);
transition: color var(--transition-fast);
}
.info-icon-button:hover {
color: rgba(255, 255, 255, 1);
}
.info-icon-button svg {
width: 18px;
height: 18px;
}
/* Info icon inside outline button */
.info-icon-button-outline {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
cursor: help;
color: var(--text-tertiary);
transition: color var(--transition-fast);
}
.info-icon-button-outline:hover {
color: var(--purple-primary);
}
.info-icon-button-outline svg {
width: 18px;
height: 18px;
}
/* Info icon inside stream type badge */
.info-icon-stream {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: var(--space-2);
cursor: help;
color: var(--text-tertiary);
transition: color var(--transition-fast);
}
.info-icon-stream:hover {
color: var(--purple-primary);
}
.info-icon-stream svg {
width: 16px;
height: 16px;
}
.frigate-output-section { .frigate-output-section {
margin-top: var(--space-6); margin-top: var(--space-6);
padding-top: var(--space-6); padding-top: var(--space-6);
@@ -987,6 +1353,128 @@ body {
display: none; display: none;
} }
/* ===== TOOLTIPS ===== */
.label-with-info {
display: flex;
align-items: center;
gap: var(--space-2);
}
.info-icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
cursor: help;
color: var(--text-tertiary);
transition: color var(--transition-fast);
}
.info-icon:hover {
color: var(--purple-primary);
}
.info-icon svg {
width: 16px;
height: 16px;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-elevated);
border: 1px solid var(--purple-primary);
border-radius: 8px;
padding: var(--space-4);
width: 320px;
max-width: 90vw;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--purple-glow);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity var(--transition-fast), visibility var(--transition-fast);
pointer-events: none;
}
/* Tooltip opens downward */
.tooltip.tooltip-down {
bottom: auto;
top: calc(100% + 8px);
}
.info-icon:hover .tooltip {
opacity: 1;
visibility: visible;
}
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--purple-primary);
}
/* Arrow for downward tooltip */
.tooltip.tooltip-down::after {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--purple-primary);
}
.tooltip-title {
font-weight: 600;
color: var(--purple-primary);
margin-bottom: var(--space-2);
font-size: var(--text-sm);
}
.tooltip-text {
font-size: var(--text-xs);
line-height: 1.5;
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
.tooltip-text:last-child {
margin-bottom: 0;
}
.tooltip-examples {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
}
.tooltip-examples-title {
font-weight: 600;
color: var(--text-primary);
font-size: var(--text-xs);
margin-bottom: var(--space-2);
}
.tooltip-example {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--purple-light);
background: var(--bg-secondary);
padding: var(--space-1) var(--space-2);
border-radius: 4px;
margin-bottom: var(--space-1);
display: block;
}
.tooltip-example:last-child {
margin-bottom: 0;
}
/* ===== UTILITIES ===== */ /* ===== UTILITIES ===== */
.hidden { .hidden {
display: none !important; display: none !important;
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Simple development server for Strix WebUI
# This allows you to test the UI without running the Go backend
PORT=${1:-8080}
echo "Starting development server on port $PORT"
echo "Open: http://localhost:$PORT?mock=true"
echo ""
echo "Press Ctrl+C to stop"
# Use Python's built-in HTTP server
cd "$(dirname "$0")"
python3 -m http.server $PORT
+356 -61
View File
@@ -8,6 +8,15 @@
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
</head> </head>
<body> <body>
<!-- Mock Mode Indicator -->
<div id="mock-mode-badge" class="mock-badge hidden">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 1v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
MOCK MODE
</div>
<div id="app"> <div id="app">
<!-- Screen 1: Initial Address Input --> <!-- Screen 1: Initial Address Input -->
<div id="screen-address" class="screen active"> <div id="screen-address" class="screen active">
@@ -33,7 +42,30 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="network-address" class="label">Network Address</label> <label for="network-address" class="label label-with-info">
Network Address
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Network Address</div>
<p class="tooltip-text">Enter the network location of your IP camera. This can be an IP address, hostname, or a complete RTSP URL.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Accepted formats:</div>
<code class="tooltip-example">192.168.1.100 - IP address only</code>
<code class="tooltip-example">camera.local - Hostname/mDNS</code>
<code class="tooltip-example">rtsp://user:pass@192.168.1.100/stream - Full URL</code>
</div>
<p class="tooltip-text"><strong>Where to find it:</strong><br>Check your camera's web interface, router's DHCP leases page, or network scanner app. Most cameras use addresses in the 192.168.x.x range.</p>
<p class="tooltip-text"><strong>Next steps:</strong><br>After entering the address, click "Check Address" to validate the camera connection and proceed to stream discovery.</p>
</div>
</span>
</label>
<input <input
type="text" type="text"
id="network-address" id="network-address"
@@ -73,7 +105,26 @@
<h2 class="screen-title">Camera Configuration</h2> <h2 class="screen-title">Camera Configuration</h2>
<div class="form-group"> <div class="form-group">
<label for="address-validated" class="label">Network Address</label> <label for="address-validated" class="label label-with-info">
Network Address
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Network Address</div>
<p class="tooltip-text">The IP address, hostname, or full RTSP URL of your camera. This is the network location where the camera can be reached.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Examples:</div>
<code class="tooltip-example">192.168.1.100</code>
<code class="tooltip-example">camera.local</code>
<code class="tooltip-example">rtsp://admin:pass@192.168.1.100</code>
</div>
<p class="tooltip-text">Find it in your camera's network settings or router's device list (DHCP leases).</p>
</div>
</span>
</label>
<div class="input-validated"> <div class="input-validated">
<input <input
type="text" type="text"
@@ -88,7 +139,27 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="camera-model" class="label">Camera Model <span class="optional">(optional)</span></label> <label for="camera-model" class="label label-with-info">
Camera Model <span class="optional">(optional)</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Camera Model</div>
<p class="tooltip-text">The manufacturer and model of your IP camera. This helps the system use optimized stream paths for your specific camera brand.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Examples:</div>
<code class="tooltip-example">Hikvision: DS-2CD2142FWD</code>
<code class="tooltip-example">Dahua: IPC-HDW4433C</code>
<code class="tooltip-example">Amcrest: IP4M-1041</code>
<code class="tooltip-example">Reolink: RLC-410</code>
</div>
<p class="tooltip-text">Find it on the camera label, in the camera's web interface (Device Info), or in your purchase documentation. Leave empty for auto-detection.</p>
</div>
</span>
</label>
<div class="autocomplete-wrapper"> <div class="autocomplete-wrapper">
<input <input
type="text" type="text"
@@ -104,18 +175,52 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="username" class="label">Username</label> <label for="username" class="label label-with-info">
Username
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Username</div>
<p class="tooltip-text">The authentication username for accessing your camera's RTSP stream. This is required for most IP cameras to access video feeds.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Common defaults:</div>
<code class="tooltip-example">admin</code>
<code class="tooltip-example">root</code>
<code class="tooltip-example">user</code>
</div>
<p class="tooltip-text">Find it in your camera setup documentation or the camera's web interface under User Management. Change default credentials for security.</p>
</div>
</span>
</label>
<input <input
type="text" type="text"
id="username" id="username"
class="input" class="input"
value="admin"
placeholder="admin" placeholder="admin"
autocomplete="off" autocomplete="off"
> >
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password" class="label">Password</label> <label for="password" class="label label-with-info">
Password
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Password</div>
<p class="tooltip-text">The authentication password for your camera's RTSP stream. This credential is used together with the username to access the video feed.</p>
<p class="tooltip-text">For security reasons, always use a strong, unique password and avoid default passwords like "12345" or "password".</p>
<p class="tooltip-text">Find it in your camera's documentation, setup guide, or change it via the camera's web interface under Security/User Management settings.</p>
</div>
</span>
</label>
<div class="input-password-wrapper"> <div class="input-password-wrapper">
<input <input
type="password" type="password"
@@ -134,11 +239,29 @@
</div> </div>
</div> </div>
<details class="advanced-section">
<summary class="advanced-toggle">Advanced</summary>
<div class="advanced-content">
<div class="form-group"> <div class="form-group">
<label for="channel" class="label">Channel</label> <label for="channel" class="label label-with-info">
Channel
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Channel Number</div>
<p class="tooltip-text">The channel number identifies which specific camera or video input to access on the device.</p>
<p class="tooltip-text"><strong>For standalone IP cameras:</strong> Always use 0 (default). Single cameras don't use channel numbers.</p>
<p class="tooltip-text"><strong>For NVR/DVR systems ONLY:</strong> Each connected camera has its own channel number. Channel numbering typically starts from 0.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">NVR/DVR channel values:</div>
<code class="tooltip-example">0 - First camera on NVR/DVR</code>
<code class="tooltip-example">1 - Second camera on NVR/DVR</code>
<code class="tooltip-example">2-15 - Additional cameras (for 4, 8, 16-channel NVRs)</code>
</div>
<p class="tooltip-text">Check your NVR's camera list in the device web interface to see the correct channel assignment for each camera.</p>
</div>
</span>
</label>
<input <input
type="number" type="number"
id="channel" id="channel"
@@ -149,8 +272,32 @@
> >
</div> </div>
<details class="advanced-section">
<summary class="advanced-toggle">Advanced</summary>
<div class="advanced-content">
<div class="form-group"> <div class="form-group">
<label class="label">Resolution <span class="optional">(optional)</span></label> <label class="label label-with-info">
Resolution <span class="optional">(optional)</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Resolution Filter</div>
<p class="tooltip-text">Optionally filter discovered streams by specific resolution. Leave empty to find all available resolutions. Use this to target specific stream qualities.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Common resolutions:</div>
<code class="tooltip-example">1920 × 1080 - Full HD (main stream)</code>
<code class="tooltip-example">1280 × 720 - HD (sub stream)</code>
<code class="tooltip-example">640 × 480 - VGA (low quality)</code>
<code class="tooltip-example">3840 × 2160 - 4K Ultra HD</code>
</div>
<p class="tooltip-text">Tip: Leave empty for initial discovery, then use specific values to find particular stream types (main vs sub streams).</p>
</div>
</span>
</label>
<div class="input-row"> <div class="input-row">
<input <input
type="number" type="number"
@@ -169,7 +316,26 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="max-streams" class="label">Max Streams</label> <label for="max-streams" class="label label-with-info">
Max Streams
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Maximum Streams</div>
<p class="tooltip-text">The maximum number of stream URLs to test during discovery. Higher values increase scan time but may find more stream variants. Lower values speed up discovery.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Recommended values:</div>
<code class="tooltip-example">5 - Quick scan (faster)</code>
<code class="tooltip-example">10 - Balanced (default)</code>
<code class="tooltip-example">20-50 - Thorough scan (slower)</code>
</div>
<p class="tooltip-text">Purpose: Controls how many different RTSP URL patterns are tested. Most cameras have 2-5 valid streams (main, sub, mobile, etc.).</p>
</div>
</span>
</label>
<input <input
type="number" type="number"
id="max-streams" id="max-streams"
@@ -207,46 +373,11 @@
<p id="progress-text" class="progress-text">Starting scan...</p> <p id="progress-text" class="progress-text">Starting scan...</p>
</div> </div>
<div class="stats">
<div class="stat">
<span class="stat-value" id="stat-tested">0</span>
<span class="stat-label">Tested</span>
</div>
<div class="stat">
<span class="stat-value stat-primary" id="stat-found">0</span>
<span class="stat-label">Found</span>
</div>
<div class="stat">
<span class="stat-value" id="stat-remaining">0</span>
<span class="stat-label">Remaining</span>
</div>
</div>
<div id="streams-section" class="streams-section hidden"> <div id="streams-section" class="streams-section hidden">
<h3 class="section-title">Found Connections</h3> <h3 class="section-title">Found Connections</h3>
<div class="carousel-wrapper"> <div id="streams-list" class="streams-list"></div>
<button id="carousel-prev" class="carousel-arrow carousel-arrow-left" aria-label="Previous stream">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div class="carousel">
<div id="carousel-track" class="carousel-track"></div>
</div>
<button id="carousel-next" class="carousel-arrow carousel-arrow-right" aria-label="Next stream">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="carousel-info">
<p id="carousel-counter" class="carousel-counter">Stream 1 of 1</p>
<div id="carousel-dots" class="carousel-dots"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -265,13 +396,51 @@
<div class="stream-selection-container"> <div class="stream-selection-container">
<div class="selected-stream-info"> <div class="selected-stream-info">
<p class="stream-label">Main Stream</p> <div class="stream-label">
<span>Main Stream</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Main Stream</div>
<p class="tooltip-text">The primary high-resolution video stream from your camera. This stream is typically used for recording and high-quality viewing.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Common uses:</div>
<code class="tooltip-example">Recording to disk</code>
<code class="tooltip-example">Live HD viewing</code>
<code class="tooltip-example">High-quality playback</code>
</div>
<p class="tooltip-text">Resolution is usually 1080p (1920×1080) or higher. Higher resolution means better quality but requires more bandwidth and storage.</p>
</div>
</span>
</div>
<p id="selected-main-type" class="selected-type"></p> <p id="selected-main-type" class="selected-type"></p>
<p id="selected-main-url" class="selected-url"></p> <p id="selected-main-url" class="selected-url"></p>
</div> </div>
<div id="sub-stream-info" class="selected-stream-info sub-stream hidden"> <div id="sub-stream-info" class="selected-stream-info sub-stream hidden">
<p class="stream-label">Sub Stream</p> <div class="stream-label">
<span>Sub Stream</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Sub Stream</div>
<p class="tooltip-text">A secondary lower-resolution video stream from your camera. This stream is optimized for object detection and reduces CPU usage.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Common uses:</div>
<code class="tooltip-example">Motion detection</code>
<code class="tooltip-example">Object detection (person, car)</code>
<code class="tooltip-example">Low-bandwidth monitoring</code>
</div>
<p class="tooltip-text">Resolution is usually 640×480 or 720p. Using a sub stream for detection significantly improves performance while maintaining recording quality on the main stream.</p>
</div>
</span>
</div>
<p id="selected-sub-type" class="selected-type"></p> <p id="selected-sub-type" class="selected-type"></p>
<p id="selected-sub-url" class="selected-url"></p> <p id="selected-sub-url" class="selected-url"></p>
<button id="btn-remove-sub" class="btn-remove-sub">Remove Sub Stream</button> <button id="btn-remove-sub" class="btn-remove-sub">Remove Sub Stream</button>
@@ -280,24 +449,41 @@
<div class="tabs"> <div class="tabs">
<div class="tabs-scroll"> <div class="tabs-scroll">
<button class="tab active" data-tab="url">URL</button> <button class="tab active" data-tab="frigate">Frigate</button>
<button class="tab" data-tab="go2rtc">Go2RTC</button> <button class="tab" data-tab="go2rtc">Go2RTC</button>
<button class="tab" data-tab="frigate">Frigate</button> <button class="tab" data-tab="url">URL</button>
</div> </div>
</div> </div>
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane active" data-pane="url"> <div class="tab-pane active" data-pane="frigate">
<pre id="config-url" class="config-code"></pre>
</div>
<div class="tab-pane" data-pane="go2rtc">
<pre id="config-go2rtc" class="config-code"></pre>
</div>
<div class="tab-pane" data-pane="frigate">
<!-- Input section for existing config --> <!-- Input section for existing config -->
<div class="frigate-input-section"> <div class="frigate-input-section">
<label class="frigate-label"> <label class="frigate-label label-with-info">
Your Current Frigate Config Your Current Frigate Config <span class="optional">(optional)</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Frigate Configuration</div>
<p class="tooltip-text">You can either create a new Frigate config or add this camera to your existing configuration.</p>
<p class="tooltip-text"><strong>Option 1: New Config (Recommended for beginners)</strong><br>Leave the example config below as-is, and the system will generate a complete working configuration for you.</p>
<p class="tooltip-text"><strong>Option 2: Add to Existing Config</strong><br>If you already have Frigate running, paste your current config.yml here. The system will intelligently add this camera without breaking your existing setup.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Where to find your config.yml:</div>
<code class="tooltip-example">Docker: /config/config.yml</code>
<code class="tooltip-example">Home Assistant addon: /config/frigate.yml</code>
<code class="tooltip-example">Standalone: /etc/frigate/config.yml</code>
</div>
<p class="tooltip-text">The generator will preserve all your existing cameras and settings, only adding the new camera configuration.</p>
</div>
</span>
<span class="hint">Paste your existing config.yml or leave the example below</span> <span class="hint">Paste your existing config.yml or leave the example below</span>
</label> </label>
<textarea <textarea
@@ -308,16 +494,72 @@
</div> </div>
<!-- Generate button --> <!-- Generate button -->
<div class="button-with-tooltip">
<button id="btn-generate-frigate" class="btn btn-primary btn-generate"> <button id="btn-generate-frigate" class="btn btn-primary btn-generate">
Generate Config Generate Config
<span class="info-icon info-icon-button">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Generate Configuration</div>
<p class="tooltip-text">This button will process your camera streams and generate a ready-to-use Frigate configuration.</p>
<p class="tooltip-text"><strong>What happens:</strong></p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Configuration includes:</div>
<code class="tooltip-example">Go2RTC streams setup</code>
<code class="tooltip-example">Camera with detect & record roles</code>
<code class="tooltip-example">Object tracking (person, car, etc.)</code>
<code class="tooltip-example">Recording settings</code>
</div>
<p class="tooltip-text">If you provided an existing config, your camera will be added to it. Otherwise, a complete new configuration will be created.</p>
<p class="tooltip-text">After generation, use Copy or Download buttons to save your config.</p>
</div>
</span>
</button> </button>
</div>
<!-- Output section (hidden by default) --> <!-- Output section (hidden by default) -->
<div id="frigate-output-section" class="frigate-output-section hidden"> <div id="frigate-output-section" class="frigate-output-section hidden">
<label class="frigate-label">Updated Config (Camera Added)</label> <label class="frigate-label label-with-info">
Updated Config (Camera Added)
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Generated Configuration</div>
<p class="tooltip-text">This is your complete Frigate configuration with the camera successfully added.</p>
<p class="tooltip-text"><strong>What's included:</strong></p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Configuration sections:</div>
<code class="tooltip-example">go2rtc: Stream definitions</code>
<code class="tooltip-example">cameras: Camera with roles</code>
<code class="tooltip-example">objects: Person, car tracking</code>
<code class="tooltip-example">record: Recording settings</code>
</div>
<p class="tooltip-text"><strong>How to use:</strong><br>Copy or download this configuration and save it as <code>config.yml</code> in your Frigate directory. Restart Frigate to apply the changes.</p>
<p class="tooltip-text">If you added to existing config, your previous cameras and settings are preserved - only the new camera was added.</p>
</div>
</span>
</label>
<pre id="config-frigate" class="config-code"></pre> <pre id="config-frigate" class="config-code"></pre>
</div> </div>
</div> </div>
<div class="tab-pane" data-pane="go2rtc">
<pre id="config-go2rtc" class="config-code"></pre>
</div>
<div class="tab-pane" data-pane="url">
<pre id="config-url" class="config-code"></pre>
</div>
</div> </div>
<div class="actions"> <div class="actions">
@@ -337,19 +579,72 @@
</div> </div>
<div class="secondary-actions"> <div class="secondary-actions">
<div class="button-with-tooltip">
<button id="btn-add-sub-stream" class="btn btn-primary"> <button id="btn-add-sub-stream" class="btn btn-primary">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg> </svg>
Add Sub Stream Add Sub Stream
<span class="info-icon info-icon-button">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Add Sub Stream</div>
<p class="tooltip-text">Add a secondary lower-resolution stream for efficient object detection and motion monitoring.</p>
<p class="tooltip-text"><strong>Why add a sub stream?</strong></p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Benefits:</div>
<code class="tooltip-example">Reduces CPU usage by 50-70%</code>
<code class="tooltip-example">Faster object detection</code>
<code class="tooltip-example">Lower bandwidth consumption</code>
<code class="tooltip-example">Main stream quality preserved</code>
</div>
<p class="tooltip-text"><strong>How it works:</strong><br>After clicking, you'll return to the stream list where you can select a lower-resolution stream (usually 640×480 or 720p). Frigate will use this for detection while recording the main stream in full quality.</p>
<p class="tooltip-text"><strong>Recommended:</strong> Most IP cameras support multiple streams. Using a sub stream is highly recommended for optimal Frigate performance.</p>
</div>
</span>
</button> </button>
</div>
<div class="button-with-tooltip">
<button id="btn-new-search" class="btn btn-outline"> <button id="btn-new-search" class="btn btn-outline">
Add Another Camera Add Another Camera
<span class="info-icon info-icon-button-outline">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Add Another Camera</div>
<p class="tooltip-text">Start the configuration process for a new camera from the beginning.</p>
<p class="tooltip-text"><strong>⚠️ Important - Save First!</strong><br>Before clicking this button, make sure to save your current configuration using Copy or Download buttons above. This will reset the form.</p>
<p class="tooltip-text"><strong>What happens:</strong></p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">The process will:</div>
<code class="tooltip-example">1. Return to address input screen</code>
<code class="tooltip-example">2. Clear current camera settings</code>
<code class="tooltip-example">3. Start fresh discovery</code>
<code class="tooltip-example">4. Generate new config for next camera</code>
</div>
<p class="tooltip-text">You can then add the new camera to your saved Frigate config by pasting it in the config field.</p>
</div>
</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Modal -->
<div id="modal-overlay" class="modal-overlay hidden"></div>
<!-- Toast Notification --> <!-- Toast Notification -->
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
+10 -1
View File
@@ -1,14 +1,23 @@
import { MockCameraSearch } from '../mock/mock-data.js';
export class CameraSearchAPI { export class CameraSearchAPI {
constructor(baseURL = null) { constructor(baseURL = null, useMock = false) {
// Use relative URLs since API and UI are on the same port // Use relative URLs since API and UI are on the same port
if (!baseURL) { if (!baseURL) {
this.baseURL = ''; this.baseURL = '';
} else { } else {
this.baseURL = baseURL; this.baseURL = baseURL;
} }
this.useMock = useMock;
this.mockAPI = useMock ? new MockCameraSearch() : null;
} }
async search(query, limit = 10) { async search(query, limit = 10) {
// Use mock API if enabled
if (this.useMock) {
return await this.mockAPI.search(query, limit);
}
const response = await fetch(`${this.baseURL}api/v1/cameras/search`, { const response = await fetch(`${this.baseURL}api/v1/cameras/search`, {
method: 'POST', method: 'POST',
headers: { headers: {
+24
View File
@@ -0,0 +1,24 @@
export class ProbeAPI {
constructor(baseURL = '') {
this.baseURL = baseURL;
}
/**
* Probe a device at the given IP address.
* Returns device info: reachable status, vendor, hostname, mDNS data.
* @param {string} ip - IP address to probe
* @returns {Promise<Object>} Probe response
*/
async probe(ip) {
const response = await fetch(
`${this.baseURL}api/v1/probe?ip=${encodeURIComponent(ip)}`
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
return await response.json();
}
}
+14 -1
View File
@@ -1,5 +1,7 @@
import { MockStreamDiscovery } from '../mock/mock-data.js';
export class StreamDiscoveryAPI { export class StreamDiscoveryAPI {
constructor(baseURL = null) { constructor(baseURL = null, useMock = false) {
// Use relative URLs since API and UI are on the same port // Use relative URLs since API and UI are on the same port
if (!baseURL) { if (!baseURL) {
this.baseURL = ''; this.baseURL = '';
@@ -7,11 +9,19 @@ export class StreamDiscoveryAPI {
this.baseURL = baseURL; this.baseURL = baseURL;
} }
this.eventSource = null; this.eventSource = null;
this.useMock = useMock;
this.mockAPI = useMock ? new MockStreamDiscovery() : null;
} }
discover(request, callbacks) { discover(request, callbacks) {
this.close(); this.close();
// Use mock API if enabled
if (this.useMock) {
this.mockAPI.discover(request, callbacks);
return;
}
fetch(`${this.baseURL}api/v1/streams/discover`, { fetch(`${this.baseURL}api/v1/streams/discover`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -91,6 +101,9 @@ export class StreamDiscoveryAPI {
} }
close() { close() {
if (this.useMock && this.mockAPI) {
this.mockAPI.close();
}
if (this.eventSource) { if (this.eventSource) {
this.eventSource.close(); this.eventSource.close();
this.eventSource = null; this.eventSource = null;
@@ -252,6 +252,15 @@ export class FrigateGenerator {
return lines; 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 * Generate camera lines for cameras section
*/ */
@@ -264,11 +273,14 @@ export class FrigateGenerator {
if (cameraInfo.subStream) { if (cameraInfo.subStream) {
// Use sub for detect, main for record // 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(' input_args: preset-rtsp-restream');
lines.push(' roles:'); lines.push(' roles:');
lines.push(' - detect'); 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(' input_args: preset-rtsp-restream');
lines.push(' roles:'); lines.push(' roles:');
lines.push(' - record'); lines.push(' - record');
@@ -280,7 +292,9 @@ export class FrigateGenerator {
lines.push(` Sub Stream: ${cameraInfo.subStreamName} # Низкое разрешение (опционально)`); lines.push(` Sub Stream: ${cameraInfo.subStreamName} # Низкое разрешение (опционально)`);
} else { } else {
// Use main for both detect and record // 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(' input_args: preset-rtsp-restream');
lines.push(' roles:'); lines.push(' roles:');
lines.push(' - detect'); lines.push(' - detect');
+183 -30
View File
@@ -1,18 +1,41 @@
import { CameraSearchAPI } from './api/camera-search.js'; import { CameraSearchAPI } from './api/camera-search.js';
import { StreamDiscoveryAPI } from './api/stream-discovery.js'; import { StreamDiscoveryAPI } from './api/stream-discovery.js';
import { ProbeAPI } from './api/probe.js';
import { MockCameraAPI } from './mock/mock-camera-api.js';
import { MockStreamAPI } from './mock/mock-stream-api.js';
import { SearchForm } from './ui/search-form.js'; import { SearchForm } from './ui/search-form.js';
import { StreamCarousel } from './ui/stream-carousel.js'; import { StreamList } from './ui/stream-list.js';
import { ConfigPanel } from './ui/config-panel.js'; import { ConfigPanel } from './ui/config-panel.js';
import { FrigateGenerator } from './config-generators/frigate/index.js'; import { FrigateGenerator } from './config-generators/frigate/index.js';
import { showToast } from './utils/toast.js'; import { showToast } from './utils/toast.js';
import { showModal } from './ui/modal.js';
class StrixApp { class StrixApp {
constructor() { constructor() {
// Check if mock mode is enabled via URL parameter
const urlParams = new URLSearchParams(window.location.search);
const isMockMode = urlParams.get('mock') === 'true';
if (isMockMode) {
console.log('🎭 Mock mode enabled - using fake data');
this.cameraAPI = new MockCameraAPI();
this.streamAPI = new MockStreamAPI();
// Show mock mode badge
const mockBadge = document.getElementById('mock-mode-badge');
if (mockBadge) {
mockBadge.classList.remove('hidden');
}
} else {
this.cameraAPI = new CameraSearchAPI(); this.cameraAPI = new CameraSearchAPI();
this.streamAPI = new StreamDiscoveryAPI(); this.streamAPI = new StreamDiscoveryAPI();
}
this.probeAPI = new ProbeAPI();
this.probeResult = null;
this.searchForm = new SearchForm(); this.searchForm = new SearchForm();
this.carousel = new StreamCarousel(); this.streamList = new StreamList();
this.configPanel = new ConfigPanel(); this.configPanel = new ConfigPanel();
this.currentAddress = ''; this.currentAddress = '';
@@ -20,15 +43,41 @@ class StrixApp {
this.selectedMainStream = null; this.selectedMainStream = null;
this.selectedSubStream = null; this.selectedSubStream = null;
this.isSelectingSubStream = false; this.isSelectingSubStream = false;
this.frigateConfigGenerated = false; // Track if Frigate config has been generated
this.init(); this.init();
} }
init() { init() {
this.setupEventListeners(); this.setupEventListeners();
this.prefillNetworkAddress();
this.showScreen('address'); this.showScreen('address');
} }
/**
* Pre-fill network address input with smart default based on server IP
*/
prefillNetworkAddress() {
const hostname = window.location.hostname;
const input = document.getElementById('network-address');
// Skip if localhost or empty
if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0') {
return;
}
// Check if hostname is an IP address (matches pattern like 192.168.1.1)
const ipPattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const match = hostname.match(ipPattern);
if (match) {
// Extract first three octets (e.g., "192.168.1." from "192.168.1.254")
const networkPrefix = `${match[1]}.${match[2]}.${match[3]}.`;
input.value = networkPrefix;
input.placeholder = `${networkPrefix}100`;
}
}
setupEventListeners() { setupEventListeners() {
// Screen 1: Address input // Screen 1: Address input
document.getElementById('btn-check-address').addEventListener('click', () => this.checkAddress()); document.getElementById('btn-check-address').addEventListener('click', () => this.checkAddress());
@@ -38,6 +87,12 @@ class StrixApp {
// Screen 2: Configuration form // Screen 2: Configuration form
document.getElementById('btn-back-to-address').addEventListener('click', () => { document.getElementById('btn-back-to-address').addEventListener('click', () => {
// Clear probe-filled fields so stale data doesn't persist
document.getElementById('camera-model').value = '';
document.getElementById('camera-model').disabled = false;
document.getElementById('camera-model').placeholder = 'Start typing...';
document.getElementById('model-disabled-hint').classList.add('hidden');
this.probeResult = null;
this.showScreen('address'); this.showScreen('address');
}); });
@@ -77,24 +132,6 @@ class StrixApp {
this.showScreen('config'); this.showScreen('config');
}); });
// Carousel navigation
document.getElementById('carousel-prev').addEventListener('click', () => {
this.carousel.prev();
});
document.getElementById('carousel-next').addEventListener('click', () => {
this.carousel.next();
});
// Keyboard navigation
document.addEventListener('keydown', (e) => {
const currentScreen = document.querySelector('.screen.active').id;
if (currentScreen === 'screen-discovery') {
if (e.key === 'ArrowLeft') this.carousel.prev();
if (e.key === 'ArrowRight') this.carousel.next();
}
});
// Screen 4: Configuration output // Screen 4: Configuration output
document.getElementById('btn-back-to-streams').addEventListener('click', () => { document.getElementById('btn-back-to-streams').addEventListener('click', () => {
this.isSelectingSubStream = false; this.isSelectingSubStream = false;
@@ -144,7 +181,88 @@ class StrixApp {
document.getElementById('address-validated').value = address; document.getElementById('address-validated').value = address;
} }
// Extract IP for probe (from full URL or raw input)
const probeIP = this.extractIPForProbe(address);
// Probe the device before proceeding
const btn = document.getElementById('btn-check-address');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Checking...';
try {
this.probeResult = await this.probeAPI.probe(probeIP);
if (this.probeResult.reachable) {
// Auto-fill vendor into Camera Model if found
if (this.probeResult.probes.arp && this.probeResult.probes.arp.vendor) {
const modelInput = document.getElementById('camera-model');
if (!modelInput.disabled && !modelInput.value) {
modelInput.value = this.probeResult.probes.arp.vendor;
}
}
this.showScreen('config'); this.showScreen('config');
} else {
// Device unreachable -- show modal
const result = await showModal({
title: 'Device Unreachable',
message: `The device at ${probeIP} is not responding. It may be offline, on a different network, or the IP address may be incorrect.`,
buttons: [
{ id: 'change', label: 'Change IP', style: 'primary' },
{ id: 'continue', label: 'Continue Anyway', style: 'outline' }
]
});
if (result === 'continue') {
this.showScreen('config');
} else {
// 'change' or null (overlay click) -- stay on address screen
input.focus();
input.select();
}
}
} catch (error) {
// Network/server error -- show modal
const result = await showModal({
title: 'Connection Error',
message: `Could not check the device: ${error.message}`,
buttons: [
{ id: 'change', label: 'Change IP', style: 'primary' },
{ id: 'continue', label: 'Continue Anyway', style: 'outline' }
]
});
if (result === 'continue') {
this.showScreen('config');
} else {
input.focus();
}
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
/**
* Extract IP address from input for probe API call.
* Handles plain IPs and full URLs like rtsp://user:pass@192.168.1.50/stream
*/
extractIPForProbe(address) {
if (this.isFullURL(address)) {
try {
const urlObj = new URL(address);
return urlObj.hostname;
} catch (e) {
return address;
}
}
// Remove port if present (e.g., "192.168.1.50:554")
const colonIndex = address.lastIndexOf(':');
if (colonIndex > 0) {
return address.substring(0, colonIndex);
}
return address;
} }
isFullURL(str) { isFullURL(str) {
@@ -155,10 +273,12 @@ class StrixApp {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
// Extract credentials // Extract credentials (only override if provided in URL)
if (urlObj.username) { if (urlObj.username) {
document.getElementById('username').value = urlObj.username; document.getElementById('username').value = urlObj.username;
} }
// If no username in URL, keep the default "admin" value
if (urlObj.password) { if (urlObj.password) {
document.getElementById('password').value = urlObj.password; document.getElementById('password').value = urlObj.password;
} }
@@ -285,11 +405,13 @@ class StrixApp {
resetDiscoveryUI() { resetDiscoveryUI() {
document.getElementById('progress-fill').style.width = '0%'; document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-text').textContent = 'Starting scan...'; document.getElementById('progress-text').textContent = 'Starting scan...';
document.getElementById('stat-tested').textContent = '0';
document.getElementById('stat-found').textContent = '0';
document.getElementById('stat-remaining').textContent = '0';
document.getElementById('streams-section').classList.add('hidden'); document.getElementById('streams-section').classList.add('hidden');
this.currentStreams = []; 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) { handleProgress(data) {
@@ -298,9 +420,6 @@ class StrixApp {
document.getElementById('progress-fill').style.width = `${percentage}%`; document.getElementById('progress-fill').style.width = `${percentage}%`;
document.getElementById('progress-text').textContent = `Testing streams... ${Math.round(percentage)}%`; document.getElementById('progress-text').textContent = `Testing streams... ${Math.round(percentage)}%`;
document.getElementById('stat-tested').textContent = data.tested;
document.getElementById('stat-found').textContent = data.found;
document.getElementById('stat-remaining').textContent = data.remaining;
} }
handleStreamFound(data) { handleStreamFound(data) {
@@ -312,8 +431,8 @@ class StrixApp {
streamsSection.classList.remove('hidden'); streamsSection.classList.remove('hidden');
} }
// Update carousel // Update stream list (smart defaults applied automatically on first render)
this.carousel.render(this.currentStreams, (stream, index) => { this.streamList.render(this.currentStreams, (stream, index) => {
this.selectStream(stream, index); this.selectStream(stream, index);
}); });
} }
@@ -338,16 +457,22 @@ class StrixApp {
// Selecting main stream // Selecting main stream
this.selectedMainStream = stream; this.selectedMainStream = stream;
this.selectedSubStream = null; this.selectedSubStream = null;
this.frigateConfigGenerated = false; // Reset Frigate config state
this.configPanel.render(this.selectedMainStream, this.selectedSubStream); this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
this.updateSubStreamUI(); this.updateSubStreamUI();
this.showScreen('output'); this.showScreen('output');
// Hide action buttons initially since Frigate tab is active by default
document.querySelector('.actions').style.display = 'none';
} else { } else {
// Selecting sub stream // Selecting sub stream
this.selectedSubStream = stream; this.selectedSubStream = stream;
this.isSelectingSubStream = false; this.isSelectingSubStream = false;
this.frigateConfigGenerated = false; // Reset Frigate config state
this.configPanel.render(this.selectedMainStream, this.selectedSubStream); this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
this.updateSubStreamUI(); this.updateSubStreamUI();
this.showScreen('output'); this.showScreen('output');
// Hide action buttons initially since Frigate tab is active by default
document.querySelector('.actions').style.display = 'none';
} }
} }
@@ -363,14 +488,28 @@ class StrixApp {
document.getElementById('frigate-output-section').classList.add('hidden'); document.getElementById('frigate-output-section').classList.add('hidden');
document.getElementById('config-frigate').textContent = ''; 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'); showToast('Select a sub stream from available streams');
this.showScreen('discovery'); this.showScreen('discovery');
} }
removeSubStream() { removeSubStream() {
this.selectedSubStream = null; this.selectedSubStream = null;
this.frigateConfigGenerated = false; // Reset Frigate config state when sub stream is removed
this.configPanel.render(this.selectedMainStream, this.selectedSubStream); this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
this.updateSubStreamUI(); this.updateSubStreamUI();
// Hide action buttons if on Frigate tab
const activeTab = document.querySelector('.tab.active').dataset.tab;
if (activeTab === 'frigate') {
document.querySelector('.actions').style.display = 'none';
}
showToast('Sub stream removed'); showToast('Sub stream removed');
} }
@@ -395,6 +534,10 @@ class StrixApp {
document.getElementById('config-frigate').textContent = newConfig; document.getElementById('config-frigate').textContent = newConfig;
document.getElementById('frigate-output-section').classList.remove('hidden'); document.getElementById('frigate-output-section').classList.remove('hidden');
// Mark as generated and show action buttons
this.frigateConfigGenerated = true;
document.querySelector('.actions').style.display = 'flex';
// Scroll to result // Scroll to result
document.getElementById('frigate-output-section').scrollIntoView({ document.getElementById('frigate-output-section').scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
@@ -429,6 +572,16 @@ class StrixApp {
// Update tab panes // Update tab panes
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelector(`.tab-pane[data-pane="${tabName}"]`).classList.add('active'); document.querySelector(`.tab-pane[data-pane="${tabName}"]`).classList.add('active');
// Show/hide action buttons based on tab and Frigate config state
const actionsContainer = document.querySelector('.actions');
if (tabName === 'frigate' && !this.frigateConfigGenerated) {
// Hide buttons on Frigate tab until config is generated
actionsContainer.style.display = 'none';
} else {
// Show buttons for other tabs or after Frigate config is generated
actionsContainer.style.display = 'flex';
}
} }
copyConfig() { copyConfig() {
@@ -482,7 +635,7 @@ class StrixApp {
document.getElementById('camera-model').value = ''; document.getElementById('camera-model').value = '';
document.getElementById('camera-model').disabled = false; document.getElementById('camera-model').disabled = false;
document.getElementById('camera-model').placeholder = 'Start typing...'; document.getElementById('camera-model').placeholder = 'Start typing...';
document.getElementById('username').value = ''; document.getElementById('username').value = 'admin'; // Reset to default value
document.getElementById('password').value = ''; document.getElementById('password').value = '';
document.getElementById('channel').value = '0'; document.getElementById('channel').value = '0';
document.getElementById('max-streams').value = '10'; document.getElementById('max-streams').value = '10';
+49
View File
@@ -0,0 +1,49 @@
// Mock implementation of CameraSearchAPI for development
export class MockCameraAPI {
constructor() {
this.mockCameras = [
{ brand: "Hikvision", model: "DS-2CD2042WD-I" },
{ brand: "Hikvision", model: "DS-2CD2142FWD-I" },
{ brand: "Hikvision", model: "DS-2CD2032-I" },
{ brand: "Hikvision", model: "DS-2CD2385G1-I" },
{ brand: "Dahua", model: "IPC-HFW4431R-Z" },
{ brand: "Dahua", model: "IPC-HDBW4433R-ZS" },
{ brand: "Dahua", model: "DH-IPC-HFW2431S-S-S2" },
{ brand: "Dahua", model: "IPC-HDW2531T-AS-S2" },
{ brand: "Axis", model: "M3046-V" },
{ brand: "Axis", model: "P3245-LVE" },
{ brand: "Axis", model: "M5525-E" },
{ brand: "Uniview", model: "IPC322SR3-DVS28-F" },
{ brand: "Uniview", model: "IPC2124SR3-DPF40" },
{ brand: "Reolink", model: "RLC-410" },
{ brand: "Reolink", model: "RLC-520A" },
{ brand: "Reolink", model: "RLC-810A" },
{ brand: "TP-Link", model: "VIGI C300HP-4" },
{ brand: "TP-Link", model: "VIGI C540V" },
{ brand: "Amcrest", model: "IP8M-2496EW" },
{ brand: "Amcrest", model: "IP4M-1041B" },
{ brand: "Foscam", model: "FI9900P" },
{ brand: "Foscam", model: "R5" },
];
}
async search(query, limit = 10) {
// Simulate network delay
await this.delay(150);
const lowerQuery = query.toLowerCase();
const filtered = this.mockCameras.filter(camera => {
const searchText = `${camera.brand} ${camera.model}`.toLowerCase();
return searchText.includes(lowerQuery);
});
return {
cameras: filtered.slice(0, limit),
total: filtered.length
};
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
+209
View File
@@ -0,0 +1,209 @@
// Mock data for development and testing
export const MOCK_CAMERAS = [
{ brand: "Hikvision", model: "DS-2CD2143G0-I" },
{ brand: "Hikvision", model: "DS-2CD2385G1-I" },
{ brand: "Hikvision", model: "DS-2CD2T85G1-I8" },
{ brand: "Dahua", model: "IPC-HFW5831E-Z5E" },
{ brand: "Dahua", model: "IPC-HDW5831R-ZE" },
{ brand: "Axis", model: "M3046-V" },
{ brand: "Axis", model: "P3245-LVE" },
{ brand: "Uniview", model: "IPC2324LB-ADZK-G" },
{ brand: "Reolink", model: "RLC-810A" },
{ brand: "TP-Link", model: "VIGI C540V" }
];
export const MOCK_STREAMS = [
{
url: "rtsp://admin:password@192.168.1.100:554/stream1",
type: "FFMPEG",
resolution: "1920x1080",
codec: "h264",
fps: 25,
bitrate: 4096,
audio: true
},
{
url: "rtsp://admin:password@192.168.1.100:554/stream2",
type: "FFMPEG",
resolution: "640x360",
codec: "h264",
fps: 15,
bitrate: 512,
audio: true
},
{
url: "http://admin:password@192.168.1.100:80/onvif/device_service",
type: "ONVIF",
resolution: "1920x1080",
codec: "h264",
fps: 25,
bitrate: 4096,
audio: false
},
{
url: "rtsp://admin:password@192.168.1.100/live/main",
type: "FFMPEG",
resolution: "2560x1440",
codec: "h265",
fps: 30,
bitrate: 6144,
audio: true
},
{
url: "rtsp://admin:password@192.168.1.100/live/sub",
type: "FFMPEG",
resolution: "704x576",
codec: "h264",
fps: 15,
bitrate: 768,
audio: false
},
{
url: "rtsp://admin:password@192.168.1.100:554/ch01/0",
type: "FFMPEG",
resolution: "3840x2160",
codec: "h265",
fps: 25,
bitrate: 8192,
audio: true
},
{
url: "rtsp://admin:password@192.168.1.100:554/ch01/1",
type: "FFMPEG",
resolution: "1280x720",
codec: "h264",
fps: 20,
bitrate: 2048,
audio: false
},
{
url: "http://admin:password@192.168.1.100:8080/video.mjpeg",
type: "MJPEG",
resolution: "1920x1080",
codec: "mjpeg",
fps: 10,
bitrate: 3072,
audio: false
},
{
url: "rtsp://admin:password@192.168.1.100/h264_stream",
type: "FFMPEG",
resolution: "1920x1080",
codec: "h264",
fps: 30,
bitrate: 4096,
audio: true
},
{
url: "http://admin:password@192.168.1.100:8081/stream.m3u8",
type: "HLS",
resolution: "1920x1080",
codec: "h264",
fps: 25,
bitrate: 4096,
audio: true
}
];
// Mock Camera Search API
export class MockCameraSearch {
async search(query, limit = 10) {
// Simulate network delay
await this.delay(100);
const results = MOCK_CAMERAS.filter(camera => {
const searchStr = `${camera.brand} ${camera.model}`.toLowerCase();
return searchStr.includes(query.toLowerCase());
});
return {
cameras: results.slice(0, limit),
total: results.length
};
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Mock Stream Discovery API
export class MockStreamDiscovery {
constructor() {
this.isRunning = false;
this.timeoutId = null;
}
discover(request, callbacks) {
this.isRunning = true;
let tested = 0;
const totalToTest = 516;
const foundStreams = [...MOCK_STREAMS];
// Initial progress
callbacks.onProgress({
tested: 0,
found: 0,
remaining: totalToTest
});
// Simulate progressive testing
const progressInterval = setInterval(() => {
if (!this.isRunning) {
clearInterval(progressInterval);
return;
}
tested += Math.floor(Math.random() * 30) + 20;
if (tested > totalToTest) tested = totalToTest;
callbacks.onProgress({
tested: tested,
found: foundStreams.length,
remaining: totalToTest - tested
});
if (tested >= totalToTest) {
clearInterval(progressInterval);
}
}, 200);
// Send found streams progressively
let streamIndex = 0;
const streamInterval = setInterval(() => {
if (!this.isRunning) {
clearInterval(streamInterval);
return;
}
if (streamIndex < foundStreams.length) {
callbacks.onStreamFound({
stream: foundStreams[streamIndex]
});
streamIndex++;
} else {
clearInterval(streamInterval);
}
}, 800);
// Complete after ~7.7 seconds
this.timeoutId = setTimeout(() => {
if (!this.isRunning) return;
callbacks.onComplete({
total_found: foundStreams.length,
duration: 7.7
});
this.isRunning = false;
}, 7700);
}
close() {
this.isRunning = false;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
}
+311
View File
@@ -0,0 +1,311 @@
// Mock implementation of StreamDiscoveryAPI for development
export class MockStreamAPI {
constructor() {
this.mockStreams = [
// RTSP Main streams (1920x1080)
{
url: "rtsp://192.168.1.100:554/Streaming/Channels/101",
path: "/Streaming/Channels/101",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
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",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
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: "1920x1080",
codec: "MJPEG",
fps: 10,
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",
type: "HLS",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
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: "1920x1080",
codec: "H.264",
fps: 20,
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",
type: "BUBBLE",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
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/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: 25,
bitrate: 4608000,
has_audio: true
}
];
}
discover(request, callbacks) {
const totalToScan = 450;
const streamsToFind = this.mockStreams;
let tested = 0;
let found = 0;
const startTime = Date.now();
// 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;
// Send progress event
if (callbacks.onProgress) {
callbacks.onProgress({
tested: tested,
found: found,
remaining: remaining
});
}
// Complete when done
if (tested >= totalToScan) {
clearInterval(progressInterval);
const duration = (Date.now() - startTime) / 1000;
if (callbacks.onComplete) {
callbacks.onComplete({
total_tested: totalToScan,
total_found: found,
duration: duration
});
}
}
}, 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() {
// Nothing to close in mock mode
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Simple modal dialog component.
* Shows a centered card with title, message, and configurable buttons.
* Returns a Promise that resolves with the clicked button's id.
*
* Usage:
* const result = await showModal({
* title: 'Device Unreachable',
* message: 'This IP is not responding.',
* buttons: [
* { id: 'change', label: 'Change IP', style: 'primary' },
* { id: 'continue', label: 'Continue Anyway', style: 'outline' }
* ]
* });
*/
let currentResolve = null;
export function showModal({ title, message, buttons }) {
return new Promise((resolve) => {
currentResolve = resolve;
const overlay = document.getElementById('modal-overlay');
// Clear previous content safely
overlay.replaceChildren();
// Build modal DOM using safe DOM methods
const modal = document.createElement('div');
modal.className = 'modal';
const titleEl = document.createElement('div');
titleEl.className = 'modal-title';
titleEl.textContent = title;
modal.appendChild(titleEl);
const messageEl = document.createElement('div');
messageEl.className = 'modal-message';
messageEl.textContent = message;
modal.appendChild(messageEl);
const actionsEl = document.createElement('div');
actionsEl.className = 'modal-actions';
buttons.forEach(btnConfig => {
const btn = document.createElement('button');
btn.className = `btn btn-${btnConfig.style || 'outline'}`;
btn.textContent = btnConfig.label;
btn.addEventListener('click', () => {
hideModal();
resolve(btnConfig.id);
});
actionsEl.appendChild(btn);
});
modal.appendChild(actionsEl);
overlay.appendChild(modal);
// Close on overlay click (outside modal)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
hideModal();
resolve(null);
}
});
// Show with animation
overlay.classList.remove('hidden');
requestAnimationFrame(() => {
overlay.classList.add('show');
});
});
}
export function hideModal() {
const overlay = document.getElementById('modal-overlay');
overlay.classList.remove('show');
setTimeout(() => {
overlay.classList.add('hidden');
overlay.replaceChildren();
}, 200);
currentResolve = null;
}
+562
View File
@@ -0,0 +1,562 @@
export class StreamList {
constructor() {
this.listContainer = document.getElementById('streams-list');
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;
// 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;
const truncatedUrl = this.truncateURL(stream.url, 60);
return `
<div class="stream-item ${isExpanded ? 'expanded' : ''}" data-index="${index}">
<div class="stream-item-header" data-index="${index}">
<div class="stream-item-main">
<div class="stream-info-left">
<div class="stream-type-badge">
${icon}
<span>${stream.type}</span>
${this.getStreamTypeTooltip(stream.type)}
</div>
<div class="stream-url-preview">${truncatedUrl}</div>
</div>
<button class="stream-toggle" data-index="${index}" aria-label="Toggle details">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="chevron">
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<button class="btn btn-primary btn-use-stream" data-index="${index}">Use Stream</button>
</div>
<div class="stream-item-details ${isExpanded ? 'visible' : ''}">
<div class="stream-url-full">${stream.url}</div>
${stream.resolution ? `<div class="stream-meta-item"><span class="meta-label">Resolution:</span> ${stream.resolution}</div>` : ''}
${stream.codec ? `<div class="stream-meta-item"><span class="meta-label">Codec:</span> ${stream.codec}${stream.fps ? `${stream.fps} fps` : ''}${stream.bitrate ? `${Math.round(stream.bitrate / 1000)} Kbps` : ''}</div>` : ''}
${stream.has_audio ? '<div class="stream-meta-item"><span class="meta-label">Audio:</span> Yes</div>' : ''}
</div>
</div>
`;
}
truncateURL(url, maxLength = 60) {
if (url.length <= maxLength) {
return url;
}
return url.substring(0, maxLength) + '...';
}
getStreamIcon(type) {
const icons = {
'FFMPEG': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="3" y="4" width="14" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="8" r="1" fill="currentColor"/><path d="M14 14l-3-2-3 2V8l3 2 3-2v6z" fill="currentColor"/></svg>',
'ONVIF': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="2" fill="currentColor"/><circle cx="10" cy="10" r="5" stroke="currentColor" stroke-width="1.5" stroke-dasharray="2 2"/><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 3"/></svg>',
'JPEG': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="3" y="4" width="14" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="8" r="1" fill="currentColor"/><path d="M3 13l4-4 3 3 5-5" stroke="currentColor" stroke-width="1.5"/></svg>',
'MJPEG': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="2" y="4" width="7" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="11" y="4" width="7" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><path d="M5 8l2 2-2 2M14 8l2 2-2 2" stroke="currentColor" stroke-width="1.5"/></svg>',
'HLS': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.5"/><path d="M10 6v8M6 10h8" stroke="currentColor" stroke-width="1.5"/></svg>',
'HTTP_VIDEO': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M7 6l6 4-6 4V6z" fill="currentColor"/><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5"/></svg>',
'BUBBLE': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="9" r="1.5" fill="currentColor"/><circle cx="10" cy="9" r="1.5" fill="currentColor"/><circle cx="13" cy="9" r="1.5" fill="currentColor"/><path d="M6 13q2 2 4 2t4-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
};
return icons[type] || icons['FFMPEG'];
}
getStreamTypeTooltip(type) {
const tooltips = {
'FFMPEG': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">FFMPEG Stream</div>
<p class="tooltip-text">Standard video stream decoded by FFmpeg. Most compatible and widely supported format for RTSP, HTTP, and other protocols.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example">✓ Universal compatibility</code>
<code class="tooltip-example">✓ Supports H.264, H.265, MJPEG</code>
<code class="tooltip-example">✓ Works with most cameras</code>
<code class="tooltip-example">✓ Best for recording</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Main streams, recording, high-quality playback. Default choice for most use cases.</p>
</div>
</span>
`,
'ONVIF': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">ONVIF Stream</div>
<p class="tooltip-text">Industry standard protocol for IP cameras. Discovered via ONVIF specification, ensuring maximum compatibility with camera features.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example">✓ Standardized protocol</code>
<code class="tooltip-example">✓ Auto-discovery support</code>
<code class="tooltip-example">✓ PTZ control capable</code>
<code class="tooltip-example">✓ Vendor-independent</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Enterprise cameras, systems requiring standardization, cameras with PTZ controls.</p>
</div>
</span>
`,
'JPEG': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">JPEG Snapshot</div>
<p class="tooltip-text">Single static image endpoint. Can be converted to video stream by repeatedly fetching images.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example">✓ Low bandwidth</code>
<code class="tooltip-example">✓ Simple HTTP request</code>
<code class="tooltip-example">✓ No streaming protocol needed</code>
<code class="tooltip-example">⚠ Limited framerate (1-10 fps)</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Thumbnails, snapshots, very low bandwidth scenarios. Not recommended for recording.</p>
</div>
</span>
`,
'MJPEG': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">MJPEG Stream</div>
<p class="tooltip-text">Motion JPEG - sequence of JPEG images transmitted continuously. Simple but bandwidth-intensive.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example">✓ Simple HTTP streaming</code>
<code class="tooltip-example">✓ No complex codecs</code>
<code class="tooltip-example">✓ Frame-by-frame</code>
<code class="tooltip-example">⚠ High bandwidth usage</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Sub streams, low-latency monitoring, simple camera integration. Higher bandwidth than H.264.</p>
</div>
</span>
`,
'HLS': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">HLS Stream</div>
<p class="tooltip-text">HTTP Live Streaming - Apple's adaptive bitrate streaming protocol. Delivers video in small chunks over HTTP.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example">✓ Adaptive bitrate</code>
<code class="tooltip-example">✓ Wide browser support</code>
<code class="tooltip-example">✓ Firewall-friendly (HTTP)</code>
<code class="tooltip-example">⚠ Higher latency (5-30s)</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Web playback, public streaming, CDN delivery. Not ideal for real-time monitoring.</p>
</div>
</span>
`,
'HTTP_VIDEO': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">HTTP Video Stream</div>
<p class="tooltip-text">Generic HTTP-based video stream. Simple protocol that works over standard web connections.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example">✓ Simple HTTP protocol</code>
<code class="tooltip-example">✓ No special ports</code>
<code class="tooltip-example">✓ Firewall-friendly</code>
<code class="tooltip-example">✓ Direct browser playback</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Quick viewing, simple setups, scenarios where RTSP ports are blocked.</p>
</div>
</span>
`,
'BUBBLE': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">BUBBLE / DVRIP Protocol</div>
<p class="tooltip-text">Proprietary protocol for Chinese DVR/NVR cameras. Also known as: ESeeCloud, dvr163, DVR-IP, NetSurveillance, Sofia protocol, XMeye SDK.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Compatible brands:</div>
<code class="tooltip-example">XMEye, Floureon, ZOSI</code>
<code class="tooltip-example">Sannce, Annke, DVR163</code>
<code class="tooltip-example">ESeeCloud, NetSurveillance</code>
</div>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example">⚠ Proprietary protocol</code>
<code class="tooltip-example">✓ Go2RTC converts to standard</code>
<code class="tooltip-example">✓ Two-way audio support</code>
<code class="tooltip-example">⚠ TCP only (port 34567)</code>
</div>
<p class="tooltip-text"><strong>Note:</strong> Automatically converted to standard RTSP format by Go2RTC. Works seamlessly with Frigate without additional configuration.</p>
</div>
</span>
`
};
return tooltips[type] || '';
}
attachEventListeners() {
// 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
if (e.target.closest('.btn-use-stream')) {
return;
}
const index = parseInt(header.dataset.index);
this.toggleExpand(index);
});
});
// Use Stream buttons
this.listContainer.querySelectorAll('.btn-use-stream').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent toggle
const index = parseInt(e.target.dataset.index);
if (this.onUseCallback) {
this.onUseCallback(this.streams[index], index);
}
});
});
}
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
this.expandedIndex = null;
} else {
// Expand new item
this.expandedIndex = index;
}
// Re-render to update state
this.render(this.streams, this.onUseCallback);
}
}