Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0205e32257 | |||
| c576b09e8b | |||
| 25f1907fc3 | |||
| cc8c3e4f14 | |||
| 4bd2de78dc | |||
| 89a7c87462 | |||
| e40dccbb90 | |||
| fe93aa329c | |||
| ddf2b4a373 | |||
| 833da5cf48 | |||
| 3fec89be7f | |||
| 4d6c2fd878 | |||
| eb8cc546c8 | |||
| 1fc345c569 | |||
| 0c0d743594 | |||
| 787919d20b | |||
| e9dc04178e | |||
| 915c1dec1b | |||
| e6828d8a22 | |||
| eedce14731 | |||
| 9975aa71de | |||
| 38e4af230f | |||
| 031e494787 | |||
| de389588ce | |||
| 4c03ad8d3c | |||
| d569a76700 | |||
| a405d6198f | |||
| 4143c267cd | |||
| 19e58db70f | |||
| 11e6ba9902 | |||
| a6e9cc2c5e | |||
| 12770ed5b9 | |||
| 90c4416709 | |||
| d602c8dfca | |||
| 596cf1ccdc | |||
| 779ae33bac | |||
| 71d6f2aac8 | |||
| 56c06dfa98 | |||
| 8bf92e6598 | |||
| 522d274dd4 | |||
| 8036d3e9be | |||
| 5b2f80f057 | |||
| e2b9802fd8 | |||
| 65a198d119 | |||
| 722c629c01 | |||
| c81d9a1e63 | |||
| 06de1c198b |
@@ -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>
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,217 +1,550 @@
|
|||||||
# 🦉 Strix - Smart IP Camera Stream Discovery System
|
# Strix
|
||||||
|
[](https://github.com/eduard256/strix/stargazers)
|
||||||

|
|
||||||
|
|
||||||
[](https://go.dev/)
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/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
|

|
||||||
|
|
||||||
### 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 |
@@ -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
@@ -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.3"
|
// 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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+200
-83
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
|
||||||
if currentTested != lastTested {
|
Tested: int(atomic.LoadInt32(&tested)),
|
||||||
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
|
Found: int(atomic.LoadInt32(&found)),
|
||||||
Tested: int(currentTested),
|
Remaining: len(streams) - int(atomic.LoadInt32(&tested)),
|
||||||
Found: int(atomic.LoadInt32(&found)),
|
})
|
||||||
Remaining: len(streams) - int(currentTested),
|
|
||||||
})
|
|
||||||
lastTested = currentTested
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -174,34 +174,38 @@ func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string {
|
|||||||
|
|
||||||
// Common placeholders
|
// Common placeholders
|
||||||
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),
|
||||||
"[WIDTH]": strconv.Itoa(ctx.Width),
|
"{CHANNEL}": strconv.Itoa(ctx.Channel), // BUBBLE protocol uses {channel}
|
||||||
"[width]": strconv.Itoa(ctx.Width),
|
"{channel}": strconv.Itoa(ctx.Channel),
|
||||||
"[HEIGHT]": strconv.Itoa(ctx.Height),
|
"{CHANNEL+1}": strconv.Itoa(ctx.Channel + 1),
|
||||||
"[height]": strconv.Itoa(ctx.Height),
|
"{channel+1}": strconv.Itoa(ctx.Channel + 1),
|
||||||
"[USERNAME]": ctx.Username,
|
"[WIDTH]": strconv.Itoa(ctx.Width),
|
||||||
"[username]": ctx.Username,
|
"[width]": strconv.Itoa(ctx.Width),
|
||||||
"[PASSWORD]": ctx.Password,
|
"[HEIGHT]": strconv.Itoa(ctx.Height),
|
||||||
"[password]": ctx.Password,
|
"[height]": strconv.Itoa(ctx.Height),
|
||||||
"[PASWORD]": ctx.Password, // Handle typo in database
|
"[USERNAME]": ctx.Username,
|
||||||
"[pasword]": ctx.Password,
|
"[username]": ctx.Username,
|
||||||
"[USER]": ctx.Username,
|
"[PASSWORD]": ctx.Password,
|
||||||
"[user]": ctx.Username,
|
"[password]": ctx.Password,
|
||||||
"[PASS]": ctx.Password,
|
"[PASWORD]": ctx.Password, // Handle typo in database
|
||||||
"[pass]": ctx.Password,
|
"[pasword]": ctx.Password,
|
||||||
"[PWD]": ctx.Password,
|
"[USER]": ctx.Username,
|
||||||
"[pwd]": ctx.Password,
|
"[user]": ctx.Username,
|
||||||
"[IP]": ctx.IP,
|
"[PASS]": ctx.Password,
|
||||||
"[ip]": ctx.IP,
|
"[pass]": ctx.Password,
|
||||||
"[PORT]": strconv.Itoa(ctx.Port),
|
"[PWD]": ctx.Password,
|
||||||
"[port]": strconv.Itoa(ctx.Port),
|
"[pwd]": ctx.Password,
|
||||||
"[AUTH]": auth, // base64(username:password) for basic auth
|
"[IP]": ctx.IP,
|
||||||
"[auth]": auth,
|
"[ip]": ctx.IP,
|
||||||
"[TOKEN]": "", // Empty for now
|
"[PORT]": strconv.Itoa(ctx.Port),
|
||||||
"[token]": "",
|
"[port]": strconv.Itoa(ctx.Port),
|
||||||
|
"[AUTH]": auth, // base64(username:password) for basic auth
|
||||||
|
"[auth]": auth,
|
||||||
|
"[TOKEN]": "", // Empty for now
|
||||||
|
"[token]": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace all placeholders
|
// Replace all placeholders
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
+67
-5
@@ -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
|
||||||
@@ -253,8 +264,9 @@ func generateClientID() string {
|
|||||||
|
|
||||||
// StreamWriter provides a simple interface for writing SSE events
|
// StreamWriter provides a simple interface for writing SSE events
|
||||||
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{
|
||||||
@@ -287,8 +302,9 @@ 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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "webui",
|
"name": "webui",
|
||||||
"version": "1.0.3",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
+589
-101
@@ -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;
|
||||||
|
|||||||
Executable
+14
@@ -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
|
||||||
+375
-80
@@ -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,23 +239,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<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
|
||||||
|
type="number"
|
||||||
|
id="channel"
|
||||||
|
class="input"
|
||||||
|
value="0"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<details class="advanced-section">
|
<details class="advanced-section">
|
||||||
<summary class="advanced-toggle">Advanced</summary>
|
<summary class="advanced-toggle">Advanced</summary>
|
||||||
<div class="advanced-content">
|
<div class="advanced-content">
|
||||||
<div class="form-group">
|
|
||||||
<label for="channel" class="label">Channel</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="channel"
|
|
||||||
class="input"
|
|
||||||
value="0"
|
|
||||||
min="0"
|
|
||||||
max="255"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 -->
|
||||||
<button id="btn-generate-frigate" class="btn btn-primary btn-generate">
|
<div class="button-with-tooltip">
|
||||||
Generate Config
|
<button id="btn-generate-frigate" class="btn btn-primary btn-generate">
|
||||||
</button>
|
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>
|
||||||
|
</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,20 +579,73 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="secondary-actions">
|
<div class="secondary-actions">
|
||||||
<button id="btn-add-sub-stream" class="btn btn-primary">
|
<div class="button-with-tooltip">
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<button id="btn-add-sub-stream" class="btn btn-primary">
|
||||||
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
</svg>
|
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
Add Sub Stream
|
</svg>
|
||||||
</button>
|
Add Sub Stream
|
||||||
<button id="btn-new-search" class="btn btn-outline">
|
<span class="info-icon info-icon-button">
|
||||||
Add Another Camera
|
<svg viewBox="0 0 16 16" fill="none">
|
||||||
</button>
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="button-with-tooltip">
|
||||||
|
<button id="btn-new-search" class="btn btn-outline">
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
+186
-33
@@ -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() {
|
||||||
this.cameraAPI = new CameraSearchAPI();
|
// Check if mock mode is enabled via URL parameter
|
||||||
this.streamAPI = new StreamDiscoveryAPI();
|
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.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.showScreen('config');
|
// 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');
|
||||||
|
} 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';
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user